techlifeadventuresVol. 03 · Aug 2026
I Shipped Three Broken Tools. Here's What I Found.
·10 min read·Development

I Shipped Three Broken Tools. Here's What I Found.

Three tools on this site shipped with real bugs: a calculator that lied, a leaked API key, a converter broken on every input. How I found and fixed them.

Note: This post describes bugs in tools on this site, all fixed as of 15 August 2026. The commit hashes are real and the code is quoted from the actual diffs.

For about four months, the SIP vs FD calculator on this site was lying to people.

Not dramatically. Not in a way anyone emailed me about. It just quietly overstated one side of the comparison by roughly 35% at the default inputs, which was enough to flip the verdict — to tell a visitor that a fixed deposit beat a systematic investment plan when the honest math said otherwise. People make money decisions with tools like this. Mine was wrong.

I found it because I sat down in August and audited my own site the way I'd audit someone else's. Three tools, three real bugs. One correctness bug that made the tool dishonest, one security bug that shipped an API key to every visitor, and one logic bug that broke a converter on essentially every input it was given.

I want to write this up honestly, because the interesting part isn't that I made mistakes. It's why these specific mistakes survived. All three were built with heavy AI assistance. All three passed my review. And in retrospect, they passed my review precisely because they were AI-assisted — the code looked competent enough that I never looked twice.

That's the thing I actually want to talk about.

Bug 1: the calculator that compared the wrong things

The SIP vs FD calculator does what it says. You enter a monthly amount, a tenure, and expected rates, and it tells you whether a monthly mutual fund SIP or a bank fixed deposit leaves you better off after tax.

Here's what it was actually doing. It took your monthly SIP amount, multiplied it by the number of months to get your total contribution, and then computed what that entire amount would earn as a lump-sum FD deposited on day one.

Read that again slowly, because I didn't for four months.

A lump sum earns compound interest for the full term. A monthly contribution earns interest only for its own shrinking remaining tenure — your final instalment earns interest for one month, not ten years. Comparing a monthly drip-feed against a day-one lump sum isn't a close-enough approximation. It's a different question, and it flattered the FD side by construction.

At the default inputs, the FD side was overstated by about 35%. Enough to flip the recommendation.

The fix (commit 9288b9d) was to compare the SIP against a recurring deposit, which is the honest peer: both sides receive the same monthly amount on the same schedule. The RD is modelled with the same quarterly-compounding convention Indian banks use, converting the nominal quarterly rate to an equivalent monthly effective rate:

text
monthlyEffectiveRate = (1 + quarterlyRate)^(1/3) − 1

Then each instalment compounds at that rate for its own remaining tenure. A separate "Lump sum" mode now answers the genuinely different question — one-time mutual fund investment against a one-time FD — because that comparison is legitimate when both sides are actually lump sums.

Now here is why this survived review, and it's the part worth generalising.

Every individual function was correct. The FD maturity math was right. The SIP future-value math was right. I could have unit-tested both of them to four decimal places and every test would have passed. The bug lived in the space between two correct functions — in the assumption that their outputs were comparable. No test I would have naturally written asserts "these two numbers answer the same question."

That is a category of bug that code review is structurally bad at catching, because review looks at code, and this bug wasn't in the code. It was in the framing.

Bug 2: the API key that shipped to every visitor

The prompt optimizer offers a free default provider so you can try it without bringing your own API key. Convenient. The implementation was a hardcoded Groq API key sitting in a file that began with 'use client'.

If you don't work in Next.js: that directive means the file is a client component. It gets compiled into the JavaScript bundle and shipped to the browser. Every visitor to that page received my API key. Not obscured, not encrypted — just there, in a file anyone could open DevTools and read.

The fix (commit 2d316f5) moved the default provider behind a server route that reads GROQ_API_KEY from server environment variables. If a visitor supplies their own key, the client still calls Groq directly, which is fine — it's their key. If they don't, the request goes through the server and the real key never leaves it.

Two smaller things came out in the same commit. The tool renders a preview that highlights vague or biased phrasing by wrapping matches in tags, and it did that through dangerouslySetInnerHTML without escaping the input first. Paste an into the box and it would execute. And every provider fetch had no timeout, so a hung endpoint would spin indefinitely. Both fixed: HTML-escape before highlighting, extracted to a pure tested highlight.ts, and a 60-second AbortController timeout on every provider call.

Why did this one survive? Because the model wrote correct code for the file it was in. Ask an AI to add a Groq provider to a React component and it will write a correct Groq provider for a React component. It doesn't know — and I didn't tell it — that this particular file crosses a trust boundary. The client/server split in Next.js is invisible in the code itself. It's one directive at the top of the file, and nothing downstream reminds you about it.

This is the class of AI-assisted bug I now watch for hardest: code that is locally correct and globally wrong. The model optimises for the file. Nobody was optimising for the boundary.

One more thing, because the alternative is dishonest: the key was exposed in a deployed production bundle, so it has to be treated as compromised and rotated, not just removed from source. Deleting a secret from a file does nothing about the copies already served to browsers. If you take one operational thing from this post, take that.

Bug 3: the converter that broke on almost everything

The markdown-to-Slack converter turns Markdown into Slack's formatting syntax. Slack uses bold with single asterisks and _italic_ with underscores, where Markdown uses bold and italic.

The original implementation did the obvious thing: a sequence of regex replacements. Convert bold first (xx), then convert italics (x_x_).

You can probably see it. The italic pass re-matched the single asterisks that the bold pass had just produced. Every bold string was converted to bold and then immediately downgraded to italic. Headers, which are also rendered with bold markers, collapsed the same way. The tool was wrong on essentially every input containing bold text, which is to say: nearly every input.

The fix (commit bce522d) rewrote it as src/lib/content/slack.ts, where each recognised piece of syntax converts to an opaque placeholder the moment it's matched, so no later pass can re-match something an earlier pass produced. Code blocks and inline code get protected first and pass through untouched.

And then that fix had its own bug. restore() was single-pass, so constructs protected early hid their contents from later passes — bold inside a link label, code inside bold, strikethrough inside bold all leaked either raw placeholder bytes or literal markdown into the output. A second commit (commit 67f9e0c) made restore() resolve nested placeholders until none remain, converted link labels and emphasis content recursively before protection, and moved link conversion ahead of emphasis so URLs and labels both survive.

Two commits, one root cause: I was doing sequential string replacement on a format that needs a parser. Regex chains on nested syntax are a well-known trap, and I walked into it anyway, because each individual regex was correct and the failure only appears when they interact.

The test file that came out of it is the useful artefact. Not expect(convert('x')).toBe('x') — that passes trivially. The one that matters:

That test asserts a property of the system, not the output of a function. It's the kind of test that would have caught all three of the bugs in this post.

The pattern, and the honest counterargument

Three bugs, three different categories — correctness, security, logic — and one thing in common. Every one of them passed a casual read. None of them would have survived a test that asserted behaviour rather than output shape.

The structural fix wasn't "review harder." It was moving logic out of the components entirely: pure modules in src/lib/, with vitest tests, testing the claim the interface makes rather than the return value of a function. deposits.ts, slack.ts, highlight.ts — none of those files existed before the audit. All three bugs lived in code that was tangled into a React component where it was awkward to test and easy to skim.

Now the counterargument, because it matters and I don't want to overclaim.

None of this proves AI-assisted code is worse. My hand-written code from 2015 had worse bugs than these, and no tests at all. I have shipped a sequential-regex formatter by hand and broken it in exactly the same way. The failure modes here are old.

What actually changed is volume and confidence. Two effects, both real:

More surface ships per hour, so more surface goes unreviewed per hour. That's arithmetic, not a judgment about code quality.

And the code reads as competent. Human-written code broadcasts uncertainty — awkward naming, a weird helper, a comment that says // TODO is this right?. Those are review signals, and they're the ones that make me slow down. AI-generated code has uniform, confident texture whether it's right or wrong. The tell is gone. I skimmed the SIP calculator because it looked like something a careful person had written, and it was — it just answered the wrong question.

What I do differently now

Short list, all of it earned:

  1. Extract logic to a pure module before you ship, not after. If it's tangled into a component you will not test it, and you will not read it carefully either.
  2. Test the claim the interface makes, not the function. "Is the FD side comparable to the SIP side" is the test that catches bug 1. Not "does fdMaturity return the right number."
  3. Treat every 'use client' file as public. Read them once with the question "would I paste this in a public gist?"
  4. Grep for key prefixes before every deploy. gsk_, sk-, and friends. It takes two seconds.
  5. Never chain regex replacements over nested syntax. Placeholder-protect or parse. There is no third option that works.
  6. When a secret leaks, rotate it. Removing it from source is cleanup, not remediation.

The editorial standards page on this site says I correct things in public when I get them wrong. This post is what that looks like when it isn't hypothetical. The calculator now compares a SIP against a recurring deposit, the methodology page documents both the convention and the correction, and if you used the old version to make a decision, I'm sorry — go run it again.

The tools, all fixed: SIP vs FD calculator, prompt optimizer, markdown to Slack converter.

Enjoying this article?

Get posts like this in your inbox. No spam, unsubscribe anytime.

Share this article
VK

Vinod Kurien Alex

Engineering Manager with 20+ years in software. Writing about AI, careers, and the Indian tech industry.

Related Articles

© 2026 TechLife AdventuresBuilt with care · v3.2.1