Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

Part I: The Monolith That Worked

Let's be honest: the old workflow.ts was fine. It built the site. It ran the tests. It started servers. It committed and pushed. It had a menu, sub-menus for tests, a wizard for build flags, and a 3-step audit flow. It worked for months.

It was also 1270 lines in a single file with 6 mutable globals and zero unit tests.

The Globals

Here's what lived at the top of the file:

let devProc: ChildProcess | null = null;
let devUrl: string | null = null;
let staticProc: ChildProcess | null = null;
let staticUrl: string | null = null;
let waitingForCommitMsg: boolean = false;
let childRunning: boolean = false;

Six variables, all mutable, all module-scoped. devProc and staticProc held live process handles. devUrl and staticUrl tracked whether each server was running. waitingForCommitMsg told the readline handler to treat the next line as a git commit message instead of a menu command. childRunning prevented SIGINT from killing the whole tool when a child execSync was still running.

These weren't bugs. They were implicit state, encoded as booleans instead of formalized as states.

The Wizard Pattern

The build sub-menu was the clearest example. It used a boolean to track whether the user was "inside" the build wizard:

let waitingForBuildMenu: boolean = false;

const buildFlags: BuildFlags = {
  toc: false, clean: false, images: false, statics: false,
  mermaid: false, minify: true, pages: true,
};

function showBuildMenu(): void {
  const on = '\x1b[32m[x]\x1b[0m';
  const off = '\x1b[31m[ ]\x1b[0m';
  console.log('\n\x1b[33m--- build options ---\x1b[0m');
  console.log(`  1 ${buildFlags.toc ? on : off} Rebuild TOC`);
  console.log(`  2 ${buildFlags.clean ? on : off} Clean output`);
  // ... 5 more flags
  process.stdout.write('\x1b[33mbuild> \x1b[0m');
}

function handleBuildMenu(input: string): void {
  switch (input) {
    case '1': buildFlags.toc = !buildFlags.toc; showBuildMenu(); break;
    case '2': buildFlags.clean = !buildFlags.clean; showBuildMenu(); break;
    // ... 5 more cases
    case '': waitingForBuildMenu = false; buildStatic(); showMenu(); break;
    case 'q': waitingForBuildMenu = false; showMenu(); break;
  }
}

The main readline loop checked waitingForBuildMenu before processing input:

rl.on('line', (input) => {
  if (waitingForBuildMenu) { handleBuildMenu(input.trim()); return; }
  if (waitingForCommitMsg) { /* ... */ return; }
  if (testState)           { handleTestInput(input.trim()); return; }
  if (auditState)          { handleAuditInput(input.trim()); return; }
  // ... main menu
});

Four different state checks. Four different boolean/object guards. The readline handler had to know about every possible "mode" the tool could be in — and they were all checked sequentially.

The Test Wizard: Three Steps Deep

The test wizard was worse. It used a step counter to track which question the user was answering:

let testState: TestState | null = null;

function showTestStep1(): void {
  testState = { step: 1 };
  console.log('--- test: what? ---');
  console.log('  1  Unit tests + coverage');
  console.log('  2  E2E tests');
  // ... 5 more options
}

function showTestStep2(): void {
  testState!.step = 2;
  console.log('--- test: target? ---');
  console.log('  1  DEV');
  console.log('  2  STATIC');
}

Step 1: pick a test suite. Step 2: pick a target. For a11y, there was even a step 1.5 (sub-menu for axe/contrast/ARIA). The state was encoded in testState.step — a number that could be 1, 2, or the string 'a11y-sub'. Three possible values, tracked in a field that TypeScript typed as number | 'a11y-sub'.

Why It Couldn't Be Tested

Every function in the file called console.log directly with ANSI escape codes:

console.log('\x1b[36m> Building toc.json...\x1b[0m');
execSync('npx tsx scripts/build-toc.ts', { stdio: 'inherit' });
console.log('\x1b[32m> Done.\x1b[0m');

No abstraction over output. No way to capture what was printed. No way to verify the menu rendered correctly without looking at a terminal.

Every build/test/audit command called execSync directly with stdio: 'inherit'. The child process owned stdout. There was no way to intercept, test, or mock the execution.

The SIGINT handler modified module-level state:

process.on('SIGINT', () => {
  if (childRunning) return;  // let child die
  if (waitingForCommitMsg) {
    waitingForCommitMsg = false;
    console.log('\n> Commit cancelled.');
    showMenu();
    return;
  }
  stopAll();
  process.exit(0);
});

Every piece of behavior was tangled with I/O, process lifecycle, and global state. Testing any of it meant testing all of it.

The Trigger

The tool had one interaction mode: a flat menu with single-letter keys and nested wizard sub-menus. I wanted to add two more:

  1. Keys mode — all commands visible as 2-character shortcuts (vim-style)
  2. Command mode — free-form text input with tab completion

Adding a second mode to the existing structure meant duplicating the entire readline handler. Adding a third meant tripling it. The boolean guards (waitingForBuildMenu, testState, auditState) would multiply. The implicit state machine encoded in those booleans was already struggling with one mode — it couldn't handle three.

That's when I realized: the state was already there. The transitions were already there. The guards were already there. They were just spread across booleans and if chains instead of formalized as a state machine.

And we'd already formalized this pattern 28 times — in the frontend.


Next: Part II: "We Already Solved This 28 Times" — The state machine pattern hiding in plain sight in src/lib/.