<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Charlie Tonneslan</title>
    <description>The latest articles on DEV Community by Charlie Tonneslan (@c-tonneslan).</description>
    <link>https://dev.to/c-tonneslan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3936350%2F9cb3dd6e-14a3-47e4-903e-0f26fb505df3.png</url>
      <title>DEV Community: Charlie Tonneslan</title>
      <link>https://dev.to/c-tonneslan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/c-tonneslan"/>
    <language>en</language>
    <item>
      <title>What it took to put six cities' affordable housing data on one map</title>
      <dc:creator>Charlie Tonneslan</dc:creator>
      <pubDate>Sun, 17 May 2026 17:16:35 +0000</pubDate>
      <link>https://dev.to/c-tonneslan/what-it-took-to-put-six-cities-affordable-housing-data-on-one-map-4d7n</link>
      <guid>https://dev.to/c-tonneslan/what-it-took-to-put-six-cities-affordable-housing-data-on-one-map-4d7n</guid>
      <description>&lt;p&gt;I had a screen open with NYC's HPD pipeline dataset on the left and San Francisco's MOHCD dataset on the right, and I was trying to answer what should have been a simple question. Who's building more housing for low-income renters per capita right now, New York or SF.&lt;/p&gt;

&lt;p&gt;The columns didn't match. NYC's records have a "borough" and an "income tier" with five buckets. SF's records have a "neighborhood" and an "AMI bracket" with three. NYC tracks construction type as "preservation" vs "new construction." SF calls it "rehab" vs "ground-up." Both have a unit count, but NYC bundles rental and homeownership into one column and SF splits them. The two cities are notionally measuring the same thing. The column-by-column overlap is maybe forty percent.&lt;/p&gt;

&lt;p&gt;That afternoon turned into the project. I have a working version now: six cities (NYC, SF, LA, DC, Chicago, Philadelphia), about 6,500 affordable housing projects on one map, shared filters across cities, a real PostGIS-backed gap analysis, and the answer to my original question, which I'll get to. The repo's at github.com/c-tonneslan/groundwork. This is what it actually took.&lt;/p&gt;

&lt;p&gt;The honest part first. There is no canonical "affordable housing" schema. Every city's housing department made up their own, on their own timeline, for their own internal reasons. NYC's HPD has been collecting unit-level data since 1987 and the schema reflects three decades of policy changes. SF's MOHCD has done the same but with different priorities. LA's HCID rolls things up differently again. DC publishes a tidy table that throws away half the detail. Chicago publishes a list of projects with no completion dates at all. (I'll come back to that one.)&lt;/p&gt;

&lt;p&gt;So normalization is the entire project, basically. You pick a target schema, you write a loader per city, you accept that some columns are going to be null for some cities. My target schema lives in a &lt;code&gt;projects&lt;/code&gt; table with the columns you'd expect: &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;address&lt;/code&gt;, &lt;code&gt;lat&lt;/code&gt;, &lt;code&gt;lng&lt;/code&gt;, &lt;code&gt;units&lt;/code&gt;, &lt;code&gt;unit_mix&lt;/code&gt;, &lt;code&gt;income_tier&lt;/code&gt;, &lt;code&gt;construction_type&lt;/code&gt;, &lt;code&gt;start_date&lt;/code&gt;, &lt;code&gt;completion_date&lt;/code&gt;, &lt;code&gt;funding_source&lt;/code&gt;, &lt;code&gt;city_id&lt;/code&gt;, &lt;code&gt;external_id&lt;/code&gt;. The loaders are one Node script per city in &lt;code&gt;scripts/load-*.mjs&lt;/code&gt;. Each one maps that city's API onto the shared shape, fills the columns it can, leaves the rest null, and upserts on &lt;code&gt;(city_id, external_id)&lt;/code&gt; so re-running it doesn't duplicate.&lt;/p&gt;

&lt;p&gt;The mapping work itself is mostly boring. This city's borough becomes our &lt;code&gt;area_id&lt;/code&gt;. This city's &lt;code&gt;tot_units&lt;/code&gt; becomes our &lt;code&gt;units&lt;/code&gt;. The interesting stuff is where the mappings don't exist. NYC tracks income tier in five bins, SF in three, LA in something else again. There's no faithful translation. So I picked the loosest common denominator (extremely low, very low, low, moderate, middle, other) and forced each city's bins into the nearest match, with an &lt;code&gt;income_tier_original&lt;/code&gt; column that preserves the source's exact label so you can audit. The choropleth on the map uses the normalized column. The detail page shows both.&lt;/p&gt;

&lt;p&gt;Two things from that surprised me. The bigger one was that admitting what's missing matters more than getting everything right. Every page on the live site has a data-quality footnote saying when this city's dataset was last updated, what's missing, and what assumptions the normalization made. A reader who actually cares about housing policy in DC versus LA will trust a tool that admits it forced three income bins into five. The reader who doesn't care isn't reading footnotes anyway.&lt;/p&gt;

&lt;p&gt;The smaller surprise, which I almost dropped to keep the data tidy, was that the city with the worst data is sometimes the most useful one to include. Chicago's affordable rental inventory doesn't ship completion dates. None of the production-over-time charts work for it. Including Chicago anyway, and being upfront about the limitation, is more useful than dropping it. A reader in Chicago can still use the map and the per-project detail. A reader doing a national comparison gets to see how big the gap is between cities that publish good data and cities that don't.&lt;/p&gt;

&lt;p&gt;Most of groundwork is plumbing. But there's one query that does the thing I built the project to do, which is to surface where the supply-demand mismatch is worst. For every census tract in a city, count the rent-burdened households (renters paying more than 30% of income on housing, from ACS 5-year), count the affordable units within 1 km of the tract centroid, order by the ratio. Worst-served tracts at the top.&lt;/p&gt;

&lt;p&gt;In PostGIS this is one query:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tract_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rent_burdened_households&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;centroid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;nearby_units&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rent_burdened_households&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;float&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="k"&gt;NULLIF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;ST_DWithin&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;centroid&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;geom&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;geography&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
  &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;burden_per_unit&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;civic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tracts&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;civic&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;projects&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city_id&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;city_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;tract_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rent_burdened_households&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;centroid&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;burden_per_unit&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt; &lt;span class="n"&gt;NULLS&lt;/span&gt; &lt;span class="k"&gt;LAST&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;ST_DWithin&lt;/code&gt; with a geography cast does the meters-native radius check. The &lt;code&gt;FILTER&lt;/code&gt; clause lets the same aggregate count once with a spatial constraint without shuttling rows out of Postgres to filter in Node. The whole thing runs in about forty milliseconds on six cities' worth of data.&lt;/p&gt;

&lt;p&gt;What I'd want a developer who's never used PostGIS to take from this is that the spatial filter has to happen at the database, not in your application code. The temptation is always to pull all the projects, pull all the tracts, and do the within-radius check in a for-loop in your service layer. That works for two cities. It doesn't work for six. It really doesn't work the moment you put a 1 km radius slider on the page and the user starts dragging it.&lt;/p&gt;

&lt;p&gt;The other thing I had to figure out, which civic-data tutorials rarely touch, is that you can't compare across cities until you've normalized to population. The first version of the map ranked tracts by raw rent-burdened household count. NYC's outer boroughs dominated the top. So did LA County. Of course they did, they're huge. So I added a &lt;code&gt;population&lt;/code&gt; column on &lt;code&gt;tract&lt;/code&gt; (ACS 5-year totals), a per-10k field on the API responses, and a toggle on the map between raw and per-capita. Per-capita re-ranks everything. Larger wealthier neighborhoods drop off the top. Dense smaller neighborhoods rise.&lt;/p&gt;

&lt;p&gt;The thing nobody mentions, that I had to figure out the hard way, is that per-capita on residential population has a problem of its own. Some places (the Loop in Chicago, downtown DC, midtown Manhattan) have small residential populations but huge daytime populations of workers, tourists, hospital patients. A per-capita-by-residents metric makes them look fine. They aren't fine. The Loop has almost no affordable housing because almost nobody lives there full time. Per-capita on residential population is correct for who-lives-there questions and wrong for who-needs-it questions. I lean on the residential version and note the caveat on the methodology page, but the right answer is to use both.&lt;/p&gt;

&lt;p&gt;What about the question I started with, NYC versus SF per capita. I'll let people who want to load the data look for themselves. Two things I noticed, though. The first is that per-capita is rarely the same answer as raw. The second is that the gap between cities that publish complete data and cities that don't is bigger than the gap between cities themselves. NYC looks bigger than SF in raw numbers, of course it does. But Chicago's missing dates are a bigger missing piece than any of the headline city-vs-city numbers ever show.&lt;/p&gt;

&lt;p&gt;The reason I built this isn't that I want everyone to use my specific tool. It's that comparing across cities should be possible from any laptop and most of the time it isn't, and that's a worse problem than the tool is. The work of normalizing is unglamorous and it's the whole project. The PostGIS query is one query. The data normalization is the rest of the year. If you're a junior councillor's staffer the day before a hearing trying to spot-check a number your boss is about to quote, the tool you wanted was someone else's normalization work. That's what civic data is. Most of it is making other people's work possible.&lt;/p&gt;

&lt;p&gt;Code's at github.com/c-tonneslan/groundwork. The Philadelphia-only sibling project (same PostGIS schema, deeper on a single city: council district briefs, displacement signals from L&amp;amp;I demolition permits, email alerts on new projects within a saved radius) lives at civic-philly.vercel.app.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>postgis</category>
      <category>civictech</category>
      <category>nextjs</category>
    </item>
    <item>
      <title>Building a linter for the bugs AI agents actually make</title>
      <dc:creator>Charlie Tonneslan</dc:creator>
      <pubDate>Sun, 17 May 2026 17:12:03 +0000</pubDate>
      <link>https://dev.to/c-tonneslan/building-a-linter-for-the-bugs-ai-agents-actually-make-3p59</link>
      <guid>https://dev.to/c-tonneslan/building-a-linter-for-the-bugs-ai-agents-actually-make-3p59</guid>
      <description>&lt;p&gt;I lost an hour last Tuesday to a function that didn't exist. The agent had written what looked like fine Postgres code, &lt;code&gt;db.QueryRowContext&lt;/code&gt; with a context and a query string and a couple of args. It compiled. Wouldn't run. Took me forty minutes to work out it had used &lt;code&gt;db.QueryRow&lt;/code&gt; (no context, different signature) inside something it called &lt;code&gt;QueryRowContext&lt;/code&gt;, and was handing five things to a function that wanted three. The build error was clear enough, in hindsight. What wasted my hour was that it looked like a hundred other build errors I'd seen, and I kept reading it as a typo I could fix in two seconds.&lt;/p&gt;

&lt;p&gt;There's a number that keeps making the rounds, that a majority of developers now say they spend more time debugging code their AI assistant wrote than debugging code they wrote themselves. I'd argue with the methodology if I didn't feel it in my own week.&lt;/p&gt;

&lt;p&gt;Sitting with the Tuesday bug, I started cataloging. It had a shape, and so did most of the bugs I'd been hitting. Hallucinated method names. Right name, wrong arity. Right arity, wrong types. A constant that got renamed three versions back and now exists only in the agent's training data. They cluster. They're not random. So I went looking for a Go linter that catches them, and when I couldn't find one I wrote it.&lt;/p&gt;

&lt;p&gt;It's a Go CLI called vouch. The first thing it does, the thing that's working as of this week, is read the output of &lt;code&gt;go build&lt;/code&gt; and tell you whether your failure looks like an AI hallucination or a normal-person bug. That distinction matters more than I'd expected, and the way I built the detector is dumber than I'd expected, so this is about both.&lt;/p&gt;

&lt;p&gt;Go already has good linters. &lt;code&gt;staticcheck&lt;/code&gt; is sharp, &lt;code&gt;golangci-lint&lt;/code&gt; bundles two dozen analyzers and is the standard at every company I know. They catch real bugs. What they don't catch is "your AI assistant called &lt;code&gt;db.WithTimeout()&lt;/code&gt; and that method doesn't exist." That's a build failure, not a lint failure, and by the time the linter runs the compiler has already given up. For a human writing Go, build failures are usually typos. You fix them in five seconds and barely register them as bugs. For AI-written Go, build failures are the most common bug class by a wide margin, and they cluster into the four shapes above. You can see all of them in a single &lt;code&gt;go build&lt;/code&gt; output, sitting next to each other, indistinguishable from a missing import. What vouch does is pull them out and label them.&lt;/p&gt;

&lt;p&gt;I wanted the first detector to be useful without being clever, so it isn't. It's a screen scraper. It shells out to &lt;code&gt;go build ./...&lt;/code&gt;, captures stderr, parses each line against a small set of regular expressions, and bins the error into one of four categories: undefined-symbol, undefined-method, arity-mismatch, type-mismatch. No language server, no AST walking, no model in the loop. &lt;code&gt;go build&lt;/code&gt; and regex. The regex was ninety minutes of work. Most of the day went into the test fixtures, which is the same proportion every tool I've built has settled into.&lt;/p&gt;

&lt;p&gt;The piece that actually matters is the &lt;code&gt;--diff&lt;/code&gt; flag. It narrows the report to lines you changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;vouch check &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--diff&lt;/span&gt; main
&lt;span class="go"&gt;internal/store/user.go:42: arity-mismatch
  ctx.WithTimeout(5 * time.Second) called with 1 arg, expected 2
  func WithTimeout(parent Context, timeout Duration) (Context, CancelFunc)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's what turns vouch from "tell me everything wrong with this codebase" into "tell me what my agent just broke." Without the diff scope, it's noise. With it, it's a five-second pre-PR check.&lt;/p&gt;

&lt;p&gt;I want to head off the obvious counterargument. A lot of the AI-code-review tooling I've seen does the obvious thing, throws the diff at a model and asks the model what's wrong. Sometimes that works. It also costs money per invocation, takes a few seconds per file, and gives you a different answer every time you run it. Deterministic checks are free, instant, and reproducible. If you've called &lt;code&gt;ctx.WithTimeout(5 * time.Second)&lt;/code&gt; with one argument I don't need a frontier model to tell me you forgot a parent context. I need &lt;code&gt;go build&lt;/code&gt; and a regex. The plan from here is to layer gopls on top for the cases the compiler alone can't catch (wrong arg order on signatures that happen to type-check, deprecated APIs), and only reach for a model at the very end, narrowed to a region the cheap checks already flagged. That's the inverse of how most of this tooling is shaped today, and the inverse is right.&lt;/p&gt;

&lt;p&gt;The bug that actually pushed me from thinking about building vouch to building it wasn't even in the four-bucket bin. I was helping an agent put together a small Go service, and it produced this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;sql&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Open&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"postgres"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dsn&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;

&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryContext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"SELECT id FROM users"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="k"&gt;defer&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Close&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compiles. Runs. Wrong in a way I didn't catch for thirty minutes, because there's no &lt;code&gt;db.Ping()&lt;/code&gt; after &lt;code&gt;sql.Open&lt;/code&gt;. The first failure mode isn't a connection error at startup, it's a panic deep inside &lt;code&gt;QueryContext&lt;/code&gt;. Classic. Mirrors a thousand Stack Overflow examples but skips the half they leave implicit. vouch doesn't catch this one yet. It's a pattern-incompleteness bug and it lives further up the difficulty curve. I started with the four-bucket detector because building real coverage on the easy class first is how you find out if the rest is worth chasing.&lt;/p&gt;

&lt;p&gt;Which gets to the part nobody else is doing. If I tell you my AI linter catches AI bugs, you should ask me how often. Precision, recall, false positive rate against an off-the-shelf staticcheck pass on the same code. Every "I built an AI code reviewer" project I've come across skips that question. They show one screenshot of one bug. They don't tell you how often the tool cries wolf. The next thing I'm building isn't the next detector, it's a real eval harness, fifty-plus real-world AI-authored PRs pulled from public GitHub history (you can find them by searching for the &lt;code&gt;Co-Authored-By: Claude&lt;/code&gt; trailer, Cursor's metadata, Devin's PR titles, or Sweep's signature), labeled for whether they introduced bugs, and a detection-rate number to put in the README.&lt;/p&gt;

&lt;p&gt;I had a moment last week where I almost started the api-shape detector before I'd ever run vouch against a real codebase. Would have been a mistake. The thing that earns a tool the right to keep growing is showing that its first claim is actually true. Code's at github.com/c-tonneslan/vouch.&lt;/p&gt;

</description>
      <category>go</category>
      <category>ai</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>What I learned opening my first sixty open source pull requests</title>
      <dc:creator>Charlie Tonneslan</dc:creator>
      <pubDate>Sun, 17 May 2026 17:11:27 +0000</pubDate>
      <link>https://dev.to/c-tonneslan/what-i-learned-opening-my-first-sixty-open-source-pull-requests-3hic</link>
      <guid>https://dev.to/c-tonneslan/what-i-learned-opening-my-first-sixty-open-source-pull-requests-3hic</guid>
      <description>&lt;p&gt;Twelve days ago my GitHub account had zero contributions on it. Not zero this year, zero ever, because I'd deleted my old account in a fit of housekeeping and started fresh from a new email. Today there are about sixty pull requests open or merged across twenty-something repos in Go, Rust, Python, and TypeScript. About half are merged. A few got rejected (one for reasons I'll get to). The rest are in review.&lt;/p&gt;

&lt;p&gt;The version of this I want to write is the version I wish I'd read at the start. Not "fork the repo, read CONTRIBUTING.md, be respectful," you already know that. The version where I tell you about the things I got wrong, often, in the order they happened.&lt;/p&gt;

&lt;p&gt;The first thing I got wrong was assuming the "good first issue" tab was a green field. It isn't. Day one, I opened the Tailscale repo, filtered "good first issue" to oldest first, picked the first one with a clean repro, and had a fix in an hour. Went to push the PR, ran &lt;code&gt;gh pr list&lt;/code&gt;, and found there was already an open PR for it dated six weeks earlier, sitting in review. This kept happening. A third of the issues I picked off that tab had a draft PR somewhere. The ones that didn't have a PR often had a maintainer comment buried in the thread saying "actually we don't want to fix this" or "blocked on another redesign, don't bother."&lt;/p&gt;

&lt;p&gt;What saved me was a single command I now run before touching anything:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh &lt;span class="nb"&gt;pr &lt;/span&gt;list &lt;span class="nt"&gt;--repo&lt;/span&gt; OWNER/REPO &lt;span class="nt"&gt;--state&lt;/span&gt; all &lt;span class="nt"&gt;--search&lt;/span&gt; &lt;span class="s2"&gt;"&amp;lt;keyword from issue&amp;gt;"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If there's already a PR you'll see it. If there's a closed PR with maintainer comments you'll learn the project's actual opinion on the issue, which is usually more useful than reading the issue itself. I skipped this once on a chi PR and ate a "duplicate of #1085, which was submitted first and merged today" comment within an hour. Annoying. Avoidable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Read CONTRIBUTING.md before writing a line
&lt;/h2&gt;

&lt;p&gt;The first PR I rushed without reading CONTRIBUTING.md was to a project with a mandatory PR template I hadn't filled out. Their &lt;code&gt;github-actions&lt;/code&gt; bot auto-closes any PR that leaves required fields blank. It auto-closed mine in five minutes. I wasn't even on the page when it happened.&lt;/p&gt;

&lt;p&gt;Same week, I got a PR rejected from rs/zerolog for a more subtle reason. I'd added a method that accepted a Go &lt;code&gt;context.Context&lt;/code&gt; and made it available on subsequent log events from that logger chain. It looked like the obvious convenience; every other modern Go logging library has something similar. The owner replied within a few hours: this is semantically incorrect, the method name implies the receiver context is being decorated, your change actually modifies the receiver, that can break code that depends on the immutability of the chain. Closed. The code was correct. The test passed. The change lived inside a single function. None of that mattered, because I'd altered the contract of a public method in a stable library that other people depend on.&lt;/p&gt;

&lt;p&gt;Two lessons from that week. Read CONTRIBUTING.md and the PR template before writing a line. Templates exist to save the maintainer's time, fill them out completely. And don't change the semantic contract of a public method in a stable library just because the change feels internally consistent. If you think a public API needs to evolve, file an issue first and let the maintainers decide. The cost of asking is small. The cost of having your refactor closed because nobody asked you to is bigger than just the time you spent.&lt;/p&gt;

&lt;p&gt;The zerolog rejection is the one that really changed how I think about API stability. I now check whether a small refactor actually changes user-observable behavior, even if it doesn't break the test suite.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reading code with no agenda finds better bugs than the issue tracker
&lt;/h2&gt;

&lt;p&gt;There's an obvious play. Open the issue tracker, pick a labeled issue, fix it. That works, and it's what I did the first few days. It's also competitive, and limits you to bugs the maintainers have already triaged.&lt;/p&gt;

&lt;p&gt;The PRs I'm most proud of came from reading code with no agenda. Two examples. The first was a stale-context bug in pgx, the Postgres driver for Go. I was reading the connection-fallback path because I wanted to understand how &lt;code&gt;target_session_attrs=prefer-standby&lt;/code&gt; actually worked. Halfway through I noticed a &lt;code&gt;ctx&lt;/code&gt; variable being shadowed inside a for-loop and then reused in a fallback branch where its deadline had already burned. Nobody had filed an issue. The maintainer merged the fix in a few hours. The second was a nil panic in Wails, the Go-to-frontend desktop framework. I was reading their app-startup path, saw that &lt;code&gt;Application.Quit&lt;/code&gt; dereferenced an inner pointer that didn't get assigned until &lt;code&gt;Run&lt;/code&gt;, wrote a five-line program that triggered the panic, and shipped a one-line fix.&lt;/p&gt;

&lt;p&gt;Both were hard to find from the issue tracker because nobody knew they existed. They came from reading. That's the angle I'd push to anyone starting out. Pick a project whose code you actually use. Read one of its packages end to end without trying to fix anything. Take notes on everything that surprises you. Some of those will be bugs.&lt;/p&gt;

&lt;p&gt;The other thing I had to retrain myself on was tests. Three early PRs sat untouched for days, and in every case the reviewer's first comment was "can you add a test?" So I started doing it by default. Even for a one-line fix, mirror the existing test pattern in the package and add a case that fails before the fix and passes after. Don't introduce a new test framework or assertion library to a file that's been using &lt;code&gt;t.Errorf&lt;/code&gt; for years; that's an instant flag. And a regression test on its own is sometimes acceptable even when the fix needs more thought. I had a sqlx PR where the maintainer asked me to split the test out as a separate commit, and that landed first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commit messages in the project's voice
&lt;/h2&gt;

&lt;p&gt;I read a hundred-ish commits from golang/go, tailscale, and a couple of Charm repos before writing my first PR, just to internalize the rhythm. Go projects almost always use &lt;code&gt;package/path: short verb-first description&lt;/code&gt;, lowercase after the colon, no trailing period, around fifty characters. The body, when there is one, explains why rather than what, in plain prose paragraphs (not bullets), wrapped at seventy-two characters. First-person is normal. Opinions are normal.&lt;/p&gt;

&lt;p&gt;Here's a real Tailscale commit body I think reads well:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Engine watchdog wrapped every wgengine.Engine method call in a goroutine with a 45s timeout and crashed the process on timeout. It was added years ago to surface deadlocks during development, but the underlying deadlocks have long since been fixed, and even when it did fire it produced obscure stack traces (from inside the watchdog goroutine, not the original caller) without buying much.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Notice the personal history ("added years ago"), the opinion ("without buying much"), no bullet list even though it has multiple reasons. None of those things are hard on their own. Getting all of them in the same paragraph reliably is the part that takes practice.&lt;/p&gt;

&lt;p&gt;Twenty PRs in, I noticed the quality of attention I was getting was uneven. Some maintainers reviewed within an hour. Some sat on PRs for a week. A few projects auto-closed mine before a human ever saw it. So I started keeping a list per-repo of "is this worth the next PR." A repo earns its way on by responding within a few days, having maintainers who comment substantively rather than just merging or closing, and having an issue tracker that isn't a graveyard. The names that consistently delivered fast useful review for me: Tailscale, the Charm projects (&lt;code&gt;huh&lt;/code&gt;, &lt;code&gt;log&lt;/code&gt;, &lt;code&gt;bubbles&lt;/code&gt;, &lt;code&gt;lipgloss&lt;/code&gt;), &lt;code&gt;pgx&lt;/code&gt;. A handful of others I quietly stopped trying after a closure or two; enough of a pattern for me, not worth the activation energy to push back. Your list will look different. The point is to have one.&lt;/p&gt;

&lt;p&gt;After fifty-something PRs, the ones that merged in under a day had basically the same shape. One logical change, not "fixed this and also cleaned up some imports while I was there." A diff under fifty lines, ideally under twenty. A test that proves the bug. A commit message that explains the why in a paragraph of plain prose. A PR body that references the issue number and adds a sentence or two about how I found the bug. No reformatting of surrounding code. The PRs that sat for a week missed at least two of those. The ones that got rejected missed all of them.&lt;/p&gt;

&lt;p&gt;If you're sitting at zero contributions and feeling like the gap is too wide, the thing I'd say is what I wish someone had said to me. Pick one or two projects whose code you already use. Read one package end to end without trying to fix anything. Take notes. When you find something that's actually wrong, file an issue first if the project asks for one, then write the smallest possible fix with a test, then write a commit message that sounds like you've worked on the project for years. Do that ten times before you let yourself think about volume. Almost every shortcut version of this (find good-first-issues, fix typos in docs, run a script that opens fifty drive-by PRs) has either been done by someone else or is the kind of thing that doesn't teach you anything. The thing that teaches you and the thing that catches a maintainer's attention are the same thing: showing that you read the code.&lt;/p&gt;

</description>
      <category>opensource</category>
      <category>beginners</category>
      <category>career</category>
      <category>github</category>
    </item>
  </channel>
</rss>
