DEV Community

Praveen Yadav
Praveen Yadav

Posted on

Keeping a Solo Project's Codebase Honest Without a Team of Reviewers

I've been building Paperwork, a PDF toolkit that runs entirely in the browser — merge, split, compress, OCR, extractive summaries, all client-side with nothing uploaded anywhere. Vue 3, TypeScript, Vite, pdf-lib and pdfjs-dist for the PDF work, qpdf-wasm for password protection, Tesseract for OCR.

This isn't about the app. It's about the small amount of process wrapped around it, because on a solo project that process is the only reviewer you've got.

The actual failure mode

Type errors and failing tests catch the obvious stuff. What they don't catch is a codebase that still compiles and still passes but is quietly getting worse: a helper function that got copy-pasted into a second file instead of imported, an old code path that nothing calls anymore but nobody deleted, a CSS override that drifts away from the rest of the design system one !important at a time. None of that shows up in npm run build. It shows up eighteen months later as "why does this file have three versions of the same 40-line function."

On a team, a reviewer catches some of this by osmosis — they've seen the codebase enough times that a duplicate function smells wrong on sight. Working alone, there is no second pair of eyes, so I wanted something that plays that role mechanically: not a linter checking style, but something checking the shape of the codebase itself.

Fallow, and specifically its audit command

Fallow is a static analyzer for TS/JS codebases — dead code, circular imports, duplication, complexity hotspots, architecture-boundary violations. It has a bunch of subcommands for deep dives (dead-code --trace, dupes, health --hotspots), but the one doing the daily work here is audit, which is built specifically to sit in a commit hook or a CI gate: it scopes everything to a diff against a base ref and combines dead-code, complexity, duplication, and CSS analysis into one pass/warn/fail verdict.

The scoping is the whole reason it's usable day to day. Running a full-repo static analysis on every commit would be slow and, worse, would surface a pile of pre-existing issues that have nothing to do with what you just wrote — the kind of noise that gets a tool uninstalled within a week. audit instead defaults to --gate new-only: pre-existing findings are still reported for context (tagged as inherited, not introduced), but only findings introduced by the current changeset can actually fail the check. That's what makes it possible to turn this on in a codebase that already has some history, without a cleanup sprint as a prerequisite.

Running it against a decent-sized range of commits in this repo gives a sense of the output shape:

$ npx fallow audit --base <older-commit>

Audit scope: 51 changed files vs ee0185462996 (0801690..HEAD)
■ Metrics: dead code 0 · complexity 0 · duplication 0

── Styling ────────────────────────────────────────
  Fix confidently
    src/style.css:187   css-selector-complexity  2 !important declarations across 4 declarations
    src/style.css:1015  css-selector-complexity  selector complexity 6
    ...
  Verify first
    src/style.css:139   css-selector-complexity  4 !important declarations across 4 declarations
    src/style.css:1012  css-token-drift          color border-color: #34372f
    ...
✓ No issues in 51 changed files
Enter fullscreen mode Exit fullscreen mode

The "fix confidently" vs "verify first" split is doing real work here — a hardcoded color that doesn't match a design token isn't automatically a bug, it might be intentional, so it gets flagged for a human decision rather than blocked outright. Dead code and duplication findings, by contrast, tend to be closer to binary (either something calls it or it doesn't), so those are the ones that actually gate.

Wiring: fast locally, thorough in CI

Locally, npm install triggers Husky's prepare script, which installs a pre-commit hook:

if git rev-parse --verify HEAD >/dev/null 2>&1; then
    npx fallow audit --changed-since HEAD
else
    echo "Skipping Fallow audit: no HEAD exists yet."
fi
Enter fullscreen mode Exit fullscreen mode

--changed-since HEAD means it's only ever looking at what's staged right now, so it runs in under a second on a normal commit. That speed matters more than people give it credit for — a check that takes 200ms gets left on; a check that takes 20 seconds gets git commit --no-verify'd the third time you're in a hurry.

CI is a single GitHub Actions job, on pull requests only:

- name: Run Fallow audit
  run: npx fallow audit --base origin/main
Enter fullscreen mode Exit fullscreen mode

Here it's --base origin/main instead of --changed-since, because the job needs the full diff for the PR, not just the tip commit — someone could have three commits on a branch and only the aggregate diff matters. This is also the actual enforcement point: the pre-commit hook is a courtesy that can be bypassed, so CI is what a PR can't merge past.

What's notably absent is a separate lint / test / build job. Those exist as npm scripts and I run them locally, but I didn't duplicate them into CI as their own steps — partly because audit's duplication and complexity checks already cover a good chunk of what a second static-analysis pass would tell me, and partly because one job that means something beats four jobs that each report a fraction of the same signal in a slightly different format.

Telling it the one thing it can't infer

Static analysis for "is this used" is fundamentally a reachability question, and reachability analysis breaks down for anything called reflectively instead of referenced by name. This project has exactly one such case: pdfjs-dist's getDocument({ BinaryDataFactory }) option takes a class, instantiates it internally, and calls .fetch() on the instance itself — no line of application code ever writes .fetch(...). Fallow's dead-code pass, correctly by its own logic, has no way to see that call.

The fix isn't an inline suppression comment at a call site that doesn't exist, and it isn't turning the rule off. It's a one-line addition to .fallowrc.jsonc that tells the analyzer about the interface:

{
  // pdfjs-dist's `getDocument({ BinaryDataFactory })` option constructs the class and
  // calls `.fetch()` on it reflectively, so no in-repo caller ever references it directly.
  "usedClassMembers": [{ "implements": "BinaryDataFactoryLike", "members": ["fetch"] }],
}
Enter fullscreen mode Exit fullscreen mode

That's a better fix than it looks: it's declarative, it's in one place instead of scattered across ignore comments, and the comment explaining why means a future edit to that interface won't silently reintroduce a false positive that someone then "fixes" by deleting the method.

The unglamorous rest of it

The rest of the setup is intentionally plain, because plain is easy to keep consistent:

ESLint runs flat config — @eslint/js recommended, typescript-eslint recommended, eslint-plugin-vue's recommended set for .vue files via vue-eslint-parser, with eslint-config-prettier loaded last specifically so no formatting rule ever disagrees with what Prettier already did. Prettier itself carries exactly three settings — single quotes, no semicolons, trailing commas — and nothing else, which is less about taste and more about not having opinions worth arguing over.

The one piece of config that isn't boilerplate is in vercel.json and mirrored in vite.config.ts:

{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        { "key": "Cross-Origin-Embedder-Policy", "value": "require-corp" },
        { "key": "Cross-Origin-Opener-Policy", "value": "same-origin" }
      ]
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

qpdf-wasm needs SharedArrayBuffer, and browsers only grant that to a page in a cross-origin-isolated context — which requires both of those headers on every response, not just the ones serving the WASM binary. Vite's dev server doesn't read vercel.json, so without a second copy of the same headers in vite.config.ts, this class of bug only shows up after a deploy, which is exactly the kind of thing you want to catch on npm run dev instead.

None of these pieces individually is a novel idea. What I'd defend is the shape of the combination: one diff-scoped structural check that's fast enough to survive being mandatory, a CI job that mirrors it rather than adding four more, and configuration that teaches the tools the two or three facts about this specific codebase they can't derive on their own. That's a smaller surface to maintain than a big rulebook, and — so far — it's caught the things a rulebook usually misses.

The full source, including the .fallowrc.jsonc, the Husky hook, and the workflow file, is at github.com/ykpraveen/pdf-util.

Top comments (0)