diff --git a/apps/web/app/(site)/process/page.tsx b/apps/web/app/(site)/process/page.tsx index f9e74be..af37b9e 100644 --- a/apps/web/app/(site)/process/page.tsx +++ b/apps/web/app/(site)/process/page.tsx @@ -1,8 +1,51 @@ import type { Metadata } from "next"; -import { Container } from "@/src/components/site/Container"; -import { SectionHeader } from "@/src/components/site/SectionHeader"; +import { + ButtonLink, + Container, + SectionHeader, + TerminalCard, +} from "@/src/components/site"; +import { ProcessStepCard } from "@/src/components/process/ProcessStepCard"; +import { PROCESS_STEPS } from "@/src/components/process/processSteps"; +import { SAMPLE_DELIVERY_REPORT } from "@/src/components/process/sampleDeliveryReport"; +import { formatDeliveryReport } from "@/src/domain/deliveryReport"; -export const metadata: Metadata = { title: "Process" }; +const TITLE = "Process: AI speed, engineering proof"; +const DESCRIPTION = + "How I ship AI-accelerated code that holds up: spec first, red, green, mutation check and a delivery report with exact commands and results on every pull request."; + +export const metadata: Metadata = { + title: TITLE, + description: DESCRIPTION, + alternates: { canonical: "/process" }, + openGraph: { + title: TITLE, + description: DESCRIPTION, + url: "/process", + type: "website", + }, +}; + +const GUARDRAILS = [ + { + title: "Pre-commit gate", + text: "Formatting and lint on staged files, then typecheck and the unit tests, run before a commit is created. Broken code does not enter history.", + }, + { + title: "Pre-push gate", + text: "Coverage runs before a push leaves my machine, and the mutation check runs when the domain logic changed. The remote only receives green work.", + }, + { + title: "CI gates the deploy", + text: "On client projects the same checks run again in CI, and a deploy only starts when they pass. My machine is not the only line of defense.", + }, + { + title: "Tests are never weakened", + text: "If a test blocks a change, I do not skip, loosen or delete it to get green. If the test is wrong, I tell you why and the spec changes with your agreement.", + }, +] as const; + +const reportLines = formatDeliveryReport(SAMPLE_DELIVERY_REPORT); export default function Page() { return ( @@ -10,9 +53,112 @@ export default function Page() { + +
+

+ The five steps +

+

+ One running example: parsing an availability, meaning a date and + weekly hours. The date 2026-02-30 must be rejected, 2028-02-29 must be + accepted, and 168 hours is the upper boundary of a week. +

+
    + {PROCESS_STEPS.map((step, index) => ( + + ))} +
+
+ +
+ + + {reportLines.map((line) => ( +

+ {line} +

+ ))} +
+

+ + Download the sample report (sample-delivery-report.md) + +

+
+ +
+

+ What I do when tests are weak or missing +

+

+ AI-built code often arrives with few tests, or with tests that only + repeat the implementation. I do not start by rewriting. I write the + expected behavior as cases for the parts that matter most, run them + against the existing code, and let the failures show what is really + broken. Then I add a mutation baseline so you can see which tests + would not notice a bug. +

+

Why mutation testing

+

+ Coverage tells you which lines ran during the tests. It does not tell + you whether the tests would notice a bug in those lines. Mutation + testing changes the code on purpose and checks that a test fails. That + is the question you care about. Coverage stays useful as a gap finder: + it points at code nothing exercises. It is not a goal, and a high + number alone proves little. +

+

+ Two systems where I run this: a Rust and Solidity liquidation system + across 7 chains, with a zero-survivor mutation gate and a coverage + ratchet, and the domain core of this website, which has its own tests + and mutation gate. +

+
+ +
+ +
    + {GUARDRAILS.map((item) => ( +
  • +

    + {item.title} +

    +

    + {item.text} +

    +
  • + ))} +
+
+ +
+

Want this on your codebase?

+

+ A 20-minute intro call is enough to see whether this fits your team. +

+ Book a 20-minute intro call +
); } diff --git a/apps/web/public/sample-delivery-report.md b/apps/web/public/sample-delivery-report.md new file mode 100644 index 0000000..cd08cae --- /dev/null +++ b/apps/web/public/sample-delivery-report.md @@ -0,0 +1,41 @@ +# Delivery report (illustrative example) + +This is an illustrative example, not a report from a real client project. +It shows the shape of the evidence attached to every pull request. + +Change: availability parsing (a date and weekly hours) +Rules: reject 2026-02-30, accept 2028-02-29, accept 0 to 168 hours per week, reject 169 + +## Gates + +``` +[pass] spec 9 cases written before code +[pass] red 9 failing for the right reason +[pass] green 9/9 pass +[pass] mutation 14 mutants, 14 caught, 0 survived +[pass] coverage changed lines 100% (23/23) +[pass] typecheck clean +[pass] lint clean +verdict: pass +``` + +## Commands + +Every gate is a command you can run yourself: + +``` +pnpm test # spec, red, green +pnpm test:mutation # mutation check on the changed files +pnpm test:coverage # coverage on changed lines +pnpm typecheck +pnpm lint +``` + +## How to read it + +- spec: behavior written as concrete cases before any implementation exists. +- red: the new cases fail because the behavior is missing, not because of an import or compile error. +- green: all cases pass with the simplest clean solution. +- mutation: the code is changed on purpose (for example `<=` becomes `<`); the tests must fail. A surviving mutant means a weak assertion or dead code. +- coverage: shows which changed lines ran. It is a gap finder, not a goal. +- verdict is pass only if every gate passes. diff --git a/apps/web/src/components/process/ProcessStepCard.tsx b/apps/web/src/components/process/ProcessStepCard.tsx new file mode 100644 index 0000000..6631bec --- /dev/null +++ b/apps/web/src/components/process/ProcessStepCard.tsx @@ -0,0 +1,30 @@ +import * as React from "react"; +import { TerminalCard } from "@/src/components/site"; +import type { ProcessStep } from "./processSteps"; + +type ProcessStepCardProps = { + readonly step: ProcessStep; + readonly index: number; +}; + +export const ProcessStepCard: React.FC = ({ + step, + index, +}) => ( +
  • +
    +

    + Step {index + 1} +

    +

    {step.title}

    +

    {step.rule}

    +
    + + {step.example.map((line) => ( +

    + {line} +

    + ))} +
    +
  • +); diff --git a/apps/web/src/components/process/processSteps.ts b/apps/web/src/components/process/processSteps.ts new file mode 100644 index 0000000..6bad75c --- /dev/null +++ b/apps/web/src/components/process/processSteps.ts @@ -0,0 +1,68 @@ +export type ProcessStep = { + readonly id: string; + readonly title: string; + readonly rule: string; + readonly exampleLabel: string; + readonly example: readonly string[]; +}; + +export const PROCESS_STEPS: readonly ProcessStep[] = [ + { + id: "spec", + title: "Spec first", + rule: "Behavior is written as concrete cases before any code: inputs, outputs, edge cases, error paths. Unclear business rules get one precise question to you, not a guess.", + exampleLabel: "Availability parsing: the cases", + example: [ + '"2026-02-30" -> rejected, not a real date', + '"2028-02-29" -> accepted, leap day', + "168 h per week -> accepted, the boundary", + "169 h per week -> rejected", + "-1 h per week -> rejected", + ], + }, + { + id: "red", + title: "Red", + rule: "The cases run and fail for the right reason: the behavior is missing, not an import error or a typo. A bug fix starts with a failing test that reproduces the bug.", + exampleLabel: "Availability parsing: first run", + example: [ + "FAIL parses a leap day", + " expected { ok: true }, received undefined", + "9 failing, all because parseAvailability does not exist yet", + ], + }, + { + id: "green", + title: "Green", + rule: "The simplest clean solution that makes the cases pass. Pure functions for the logic, side effects pushed to thin edges. Then refactor while the tests stay green.", + exampleLabel: "Availability parsing: second run", + example: [ + "9/9 pass", + "date check: month length plus leap-year rule", + "hours check: whole number from 0 to 168", + ], + }, + { + id: "mutation", + title: "Mutation check", + rule: "A tool changes the code on purpose and reruns the tests. Every change must make a test fail. A surviving mutant means a weak assertion or dead code, and I fix one or the other.", + exampleLabel: "Availability parsing: mutants", + example: [ + "hours <= 168 -> hours < 168", + " caught by the 168 h case", + "day <= daysInMonth -> day < daysInMonth", + " caught by the 2028-02-29 case", + ], + }, + { + id: "report", + title: "Delivery report", + rule: "Every pull request carries the exact commands and their results: case counts, mutation result, coverage on changed lines, typecheck and lint. You can rerun every line.", + exampleLabel: "Availability parsing: the report", + example: [ + "spec 9 -> red 9 -> green 9/9", + "mutation 14 caught, 0 survived", + "see the full sample below", + ], + }, +]; diff --git a/apps/web/src/components/process/sampleDeliveryReport.ts b/apps/web/src/components/process/sampleDeliveryReport.ts new file mode 100644 index 0000000..a3026f6 --- /dev/null +++ b/apps/web/src/components/process/sampleDeliveryReport.ts @@ -0,0 +1,16 @@ +import type { DeliveryReport } from "@/src/domain/deliveryReport"; + +// Illustrative numbers for the availability parsing example. Not a real project. +export const SAMPLE_DELIVERY_REPORT: DeliveryReport = { + specCases: 9, + redFailing: 9, + greenPassing: 9, + greenTotal: 9, + mutantsTotal: 14, + mutantsCaught: 14, + mutantsSurvived: 0, + changedLinesCovered: 23, + changedLinesTotal: 23, + typecheckErrors: 0, + lintErrors: 0, +}; diff --git a/apps/web/src/domain/deliveryReport.spec.ts b/apps/web/src/domain/deliveryReport.spec.ts new file mode 100644 index 0000000..e07e1ec --- /dev/null +++ b/apps/web/src/domain/deliveryReport.spec.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vitest"; +import { + formatDeliveryReport, + verdictOf, + type DeliveryReport, +} from "./deliveryReport"; + +const passing: DeliveryReport = { + specCases: 9, + redFailing: 9, + greenPassing: 9, + greenTotal: 9, + mutantsTotal: 14, + mutantsCaught: 14, + mutantsSurvived: 0, + changedLinesCovered: 23, + changedLinesTotal: 23, + typecheckErrors: 0, + lintErrors: 0, +}; + +describe("verdictOf", () => { + it("is pass when every gate passes", () => { + expect(verdictOf(passing)).toBe("pass"); + }); + + it.each<[string, Partial]>([ + ["no spec cases", { specCases: 0, redFailing: 0 }], + ["red does not match the written cases", { redFailing: 8 }], + ["a green test fails", { greenPassing: 8 }], + ["no green tests ran", { greenPassing: 0, greenTotal: 0 }], + ["a mutant survived", { mutantsCaught: 13, mutantsSurvived: 1 }], + ["no mutants were generated", { mutantsTotal: 0, mutantsCaught: 0 }], + ["caught plus survived does not add up", { mutantsCaught: 13 }], + [ + "a survivor is reported although all mutants count as caught", + { mutantsSurvived: 1 }, + ], + ["a changed line is uncovered", { changedLinesCovered: 22 }], + [ + "no changed lines measured", + { changedLinesCovered: 0, changedLinesTotal: 0 }, + ], + ["typecheck has errors", { typecheckErrors: 1 }], + ["lint has errors", { lintErrors: 2 }], + ])("is fail when %s", (_name, patch) => { + expect(verdictOf({ ...passing, ...patch })).toBe("fail"); + }); +}); + +describe("formatDeliveryReport", () => { + it("renders one line per gate and a verdict for a passing report", () => { + expect(formatDeliveryReport(passing)).toEqual([ + "[pass] spec 9 cases written before code", + "[pass] red 9 failing for the right reason", + "[pass] green 9/9 pass", + "[pass] mutation 14 mutants, 14 caught, 0 survived", + "[pass] coverage changed lines 100% (23/23)", + "[pass] typecheck clean", + "[pass] lint clean", + "verdict: pass", + ]); + }); + + it("marks failing gates with their numbers", () => { + const lines = formatDeliveryReport({ + ...passing, + redFailing: 8, + greenPassing: 8, + mutantsCaught: 13, + mutantsSurvived: 1, + changedLinesCovered: 22, + typecheckErrors: 3, + lintErrors: 1, + }); + expect(lines).toEqual([ + "[pass] spec 9 cases written before code", + "[fail] red 8 failing, expected 9", + "[fail] green 8/9 pass", + "[fail] mutation 14 mutants, 13 caught, 1 survived", + "[fail] coverage changed lines 95.6% (22/23)", + "[fail] typecheck 3 errors", + "[fail] lint 1 error", + "verdict: fail", + ]); + }); + + it("never rounds a partial coverage up to 100", () => { + const lines = formatDeliveryReport({ + ...passing, + changedLinesCovered: 9999, + changedLinesTotal: 10000, + }); + expect(lines).toContain( + "[fail] coverage changed lines 99.9% (9999/10000)", + ); + }); + + it("reports empty measurements as failures", () => { + const lines = formatDeliveryReport({ + ...passing, + specCases: 0, + redFailing: 0, + greenPassing: 0, + greenTotal: 0, + mutantsTotal: 0, + mutantsCaught: 0, + changedLinesCovered: 0, + changedLinesTotal: 0, + }); + expect(lines.slice(0, 5)).toEqual([ + "[fail] spec no cases written", + "[fail] red no cases written", + "[fail] green 0/0 pass", + "[fail] mutation no mutants generated", + "[fail] coverage no changed lines measured", + ]); + expect(lines.at(-1)).toBe("verdict: fail"); + }); +}); diff --git a/apps/web/src/domain/deliveryReport.ts b/apps/web/src/domain/deliveryReport.ts new file mode 100644 index 0000000..6ebcd01 --- /dev/null +++ b/apps/web/src/domain/deliveryReport.ts @@ -0,0 +1,120 @@ +export type DeliveryReport = { + readonly specCases: number; + readonly redFailing: number; + readonly greenPassing: number; + readonly greenTotal: number; + readonly mutantsTotal: number; + readonly mutantsCaught: number; + readonly mutantsSurvived: number; + readonly changedLinesCovered: number; + readonly changedLinesTotal: number; + readonly typecheckErrors: number; + readonly lintErrors: number; +}; + +export type Verdict = "pass" | "fail"; + +type Gate = { + readonly name: string; + readonly pass: boolean; + readonly detail: string; +}; + +const NAME_COLUMN_WIDTH = 11; +const PERCENT_SCALE = 1000; +const PERCENT_DIVISOR = 10; + +const plural = (count: number, singular: string): string => + `${count} ${singular}${count === 1 ? "" : "s"}`; + +// Floor, never round: 99.96% must not be shown as 100%. +const floorPercent = (part: number, whole: number): number => + Math.floor((part / whole) * PERCENT_SCALE) / PERCENT_DIVISOR; + +const specGate = (r: DeliveryReport): Gate => + r.specCases > 0 + ? { + name: "spec", + pass: true, + detail: `${r.specCases} cases written before code`, + } + : { name: "spec", pass: false, detail: "no cases written" }; + +const redGate = (r: DeliveryReport): Gate => { + if (r.specCases === 0) { + return { name: "red", pass: false, detail: "no cases written" }; + } + return r.redFailing === r.specCases + ? { + name: "red", + pass: true, + detail: `${r.redFailing} failing for the right reason`, + } + : { + name: "red", + pass: false, + detail: `${r.redFailing} failing, expected ${r.specCases}`, + }; +}; + +const greenGate = (r: DeliveryReport): Gate => ({ + name: "green", + pass: r.greenTotal > 0 && r.greenPassing === r.greenTotal, + detail: `${r.greenPassing}/${r.greenTotal} pass`, +}); + +const mutationGate = (r: DeliveryReport): Gate => { + if (r.mutantsTotal === 0) { + return { name: "mutation", pass: false, detail: "no mutants generated" }; + } + return { + name: "mutation", + pass: r.mutantsSurvived === 0 && r.mutantsCaught === r.mutantsTotal, + detail: `${r.mutantsTotal} mutants, ${r.mutantsCaught} caught, ${r.mutantsSurvived} survived`, + }; +}; + +const coverageGate = (r: DeliveryReport): Gate => { + if (r.changedLinesTotal === 0) { + return { + name: "coverage", + pass: false, + detail: "no changed lines measured", + }; + } + const percent = floorPercent(r.changedLinesCovered, r.changedLinesTotal); + return { + name: "coverage", + pass: r.changedLinesCovered === r.changedLinesTotal, + detail: `changed lines ${percent}% (${r.changedLinesCovered}/${r.changedLinesTotal})`, + }; +}; + +const errorCountGate = (name: string, errors: number): Gate => ({ + name, + pass: errors === 0, + detail: errors === 0 ? "clean" : plural(errors, "error"), +}); + +const gatesOf = (r: DeliveryReport): readonly Gate[] => [ + specGate(r), + redGate(r), + greenGate(r), + mutationGate(r), + coverageGate(r), + errorCountGate("typecheck", r.typecheckErrors), + errorCountGate("lint", r.lintErrors), +]; + +export const verdictOf = (report: DeliveryReport): Verdict => + gatesOf(report).every((gate) => gate.pass) ? "pass" : "fail"; + +export const formatDeliveryReport = ( + report: DeliveryReport, +): readonly string[] => [ + ...gatesOf(report).map( + (gate) => + `[${gate.pass ? "pass" : "fail"}] ${gate.name.padEnd(NAME_COLUMN_WIDTH)}${gate.detail}`, + ), + `verdict: ${verdictOf(report)}`, +];