One of my API documentation pages went from 33,000 lines to 140,000.
Not because I added more content. Because a single operator change in my template engine turned list accumulation into character-by-character string iteration.
This post is the readable version of that failure: what broke, why it broke, and what I changed so the same class of bug does less damage next time.
The symptom
Bengal's autodoc generates HTML for Python API reference pages. I noticed something wrong: the health module's base page (/api/bengal/health/base/) was enormous. A quick check confirmed it:
wc -l site/public/api/bengal/health/base/index.html
# 139993
For a module with one class and one method, that number is absurd. Opening the file revealed thousands of repeating blocks:
<details class="autodoc-member" data-member="method">
<code class="autodoc-member-name"></code>
...
</details>
Empty member names. Over 40,000 of them. The same pattern repeated again and again. Something had turned a single accumulation loop into a flood of garbage output.
The investigation
I traced the output back to the autodoc members template. The pattern that builds the member list:
{% for el in all_members %}
{% let m = el | member_view %}
{% if m.is_private %}
{% export internal_members = internal_members + [m] %}
{% else %}
{% export public_members = public_members + [m] %}
{% end %}
{% end %}
members + [m] is list concatenation. In Python, that should append one item. So why would it produce 40,000 empty entries?
I bisected Kida from v0.2.3 (good) to v0.2.4 (bad) with an automated reproducer. The first bad commit: ef3b78e — "improvements for expressiveness."
The root cause
The regression came from add_polymorphic() in Kida's template helpers. I had added more permissive + behavior so patterns like {{ count + " items" }} and {{ "Hello " + name }} worked without requiring ~ for string concatenation.
The implementation looked roughly like this:
- Numeric:
int + int→ numeric add ✓ - Everything else:
str(left) + str(right)— "numeric add else string concat"
That broke list + list. Lists are not numeric, so they fell through to string concatenation.
So members + [m], a list plus a list, was incorrectly treated as string concatenation. When you iterate over a string in Python, you get characters. The template engine was effectively doing this:
# Intended: list accumulation
members = members + [m] # [a, b] + [c] → [a, b, c]
# What happened: string concatenation
members = members + [m] # [a, b] + [c] → "[a, b]c" (string)
# Then iteration over that string produced one output per character
The export accumulation pattern, members = members + [m], is common in Kida templates. I use it for building lists in loops. The permissive + behavior was trying to be helpful. It was not.
Fix 1: Kida — refine, don't revert
I didn't revert the behavior. I fixed it.
The fix in add_polymorphic():
- Keep numeric
+for numbers - Keep string ergonomics when either operand is string-like (
count + " items"still works) - Otherwise: fall through to Python's native
left + right— solist + list,tuple + tuple, etc. work correctly
The bug was the over-broad "else string concat" fallback. Handle string cases explicitly and let Python handle everything else.
I added targeted regression tests:
# tests/test_kida_expressions.py
def test_list_plus_list_stays_list_concat():
"""list + list must not become string concat."""
...
def test_export_list_accumulation_renders_correctly():
"""Export accumulation pattern must not stringify."""
...
Before the fix: 12 <code> entries from ['a', 'b']. After: 2. Correct.
Fix 2: Bengal — defense in depth
Even with Kida fixed, I added hardening in Bengal's autodoc. If the template engine ever produces malformed output again, or if inheritance synthesis produces empty members, Bengal should filter it out rather than blindly render it.
Filters at three layers:
- Template (
members.html): Only add members with non-empty names to the display lists. - Extraction (
extractor.py): Filter out class children with empty or whitespace-only names before building the member list. - Inheritance (
inheritance.py): Skip inherited members with empty names, and skip known stdlib bases (ABC, object) that could wrongly resolve.
The Python autodoc path had no guard against malformed output. Now it does.
The same page that was 33k before the bug and 140k during it is now about 11k, leaner than the original baseline. The inheritance and empty-name filters removed stdlib noise I had not intentionally filtered before.
I fixed the bug and ended up with better docs.
What I learned
-
Don't fall through to stringify. When
+handles types it doesn't explicitly know about by converting them to strings, any new type case silently breaks an existing one. The fix: be conservative. Handle what you intend to handle. Let Python handle everything else. -
Bisection works. An automated reproducer plus
git bisectfound the exact commit in minutes. Without that, I would have been guessing. The investment in a reproducible test case pays back immediately. -
Fix the root cause. Also harden the consumer. Kida gets the fix. Bengal gets the filters. If Kida regresses again, Bengal's downstream guards limit the blast radius. Defense in depth is not about distrusting your own code. It is about knowing that trust has a half-life.
-
The ecosystem is the test suite. I found this because Bengal runs autodoc against real documentation at real scale. A unit test on a two-item list would have caught the operator behavior. The 140k-line output caught it in a way that was impossible to miss. Both matter. The real workload finds the bugs the synthetic tests don't know to ask for.
Further reading
- Static Analysis for Templates — what Kida can tell you about a template before you render it, and why that changes what's possible downstream
- The Vertical Stack Thesis — why owning both Kida and Bengal meant this bug had nowhere to hide
- Kida documentation — Expressions — the operator semantics that were at issue here