<?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: Revin</title>
    <description>The latest articles on DEV Community by Revin (@revinsoftware).</description>
    <link>https://dev.to/revinsoftware</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.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4015647%2F4c05d90d-38b0-4993-8f2b-2eac6bbdbff5.png</url>
      <title>DEV Community: Revin</title>
      <link>https://dev.to/revinsoftware</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/revinsoftware"/>
    <language>en</language>
    <item>
      <title>I counted scope changes with gh and jq: 41 of 118 issues arrived after the freeze, 3 got a new date</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Fri, 28 Aug 2026 13:22:47 +0000</pubDate>
      <link>https://dev.to/revinsoftware/i-counted-scope-changes-with-gh-and-jq-41-of-118-issues-arrived-after-the-freeze-3-got-a-new-date-3n19</link>
      <guid>https://dev.to/revinsoftware/i-counted-scope-changes-with-gh-and-jq-41-of-118-issues-arrived-after-the-freeze-3-got-a-new-date-3n19</guid>
      <description>&lt;p&gt;A delivery I estimated at three weeks landed closer to seven. The code the ticket actually described was finished in the first week, more or less on time. The other four weeks went somewhere, and for a while the only account I had of them was a feeling.&lt;/p&gt;

&lt;p&gt;Before I wrote software for a living I signed off on steel structures: cement plants, one racetrack. Sites run late too, sometimes badly. What a site has that my repository did not is the monthly measurement: a document listing what went up, what did not go up and why, signed by both sides long before the schedule blows. The delay gets assembled in pieces. In my project the delay showed up finished, in one meeting, at the end.&lt;/p&gt;

&lt;p&gt;A 2012 question on Software Engineering Stack Exchange asking why IT cannot deliver large projects quickly like other industries has 123,583 views, a score of 543 and 31 answers. Most of them talk about estimation and essential complexity. I wanted a number instead of an opinion, so I went looking for the four missing weeks in data I already had: the issue tracker.&lt;/p&gt;

&lt;h2&gt;
  
  
  First attempt: a label nobody applied
&lt;/h2&gt;

&lt;p&gt;At kickoff we agreed on a label called &lt;code&gt;scope-change&lt;/code&gt;. Anything requested after the scope was agreed would carry it. Simple, free, and it failed.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;gh issue list &lt;span class="nt"&gt;--milestone&lt;/span&gt; &lt;span class="s2"&gt;"Release 2"&lt;/span&gt; &lt;span class="nt"&gt;--state&lt;/span&gt; all &lt;span class="nt"&gt;--limit&lt;/span&gt; 300 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; number,title,createdAt,closedAt,labels &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; issues.json

jq &lt;span class="s1"&gt;'[.[] | select(any(.labels[]; .name == "scope-change"))] | length'&lt;/span&gt; issues.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two. Both created by me, both on the same afternoon, both in the first month. A label works only when a human remembers to apply it at the exact moment he is under pressure to keep the work moving. Nobody labels a request that arrives as "quick thing, five minutes".&lt;/p&gt;

&lt;p&gt;Second attempt, also bad: I tried churn per week as a proxy, lines added and removed from &lt;code&gt;git log --numstat&lt;/code&gt;. The chart was flat and useless. Rework and new scope look identical in a diff, which is the whole reason the status meeting stays green.&lt;/p&gt;

&lt;h2&gt;
  
  
  What worked: creation date against the freeze date
&lt;/h2&gt;

&lt;p&gt;The one field nobody has to maintain is &lt;code&gt;createdAt&lt;/code&gt;. The tracker writes it whether anyone cares or not. So the question became mechanical: how much of what we shipped in this milestone did not exist when we agreed on the date?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;FREEZE&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;2026-03-09T00:00:00Z

&lt;span class="c"&gt;# everything in the milestone&lt;/span&gt;
jq &lt;span class="s1"&gt;'length'&lt;/span&gt; issues.json

&lt;span class="c"&gt;# everything born after we agreed on the date&lt;/span&gt;
jq &lt;span class="nt"&gt;--arg&lt;/span&gt; freeze &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FREEZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'[.[] | select(.createdAt &amp;gt; $freeze)] | length'&lt;/span&gt; issues.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;118
41
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Around 35% of the milestone was written after the estimate that the milestone was judged by. Broken down by month, the shape is worse than the total:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; freeze &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FREEZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s1"&gt;'
  [.[] | select(.createdAt &amp;gt; $freeze)]
  | group_by(.createdAt[0:7])
  | map({month: .[0].createdAt[0:7], added: length})
  | .[] | "\(.month)  \(.added)"
'&lt;/span&gt; issues.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csvs"&gt;&lt;code&gt;&lt;span class="mf"&gt;2026&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;03&lt;/span&gt;   &lt;span class="mf"&gt;6&lt;/span&gt;
&lt;span class="mf"&gt;2026&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;04&lt;/span&gt;   &lt;span class="mf"&gt;11&lt;/span&gt;
&lt;span class="mf"&gt;2026&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;05&lt;/span&gt;   &lt;span class="mf"&gt;17&lt;/span&gt;
&lt;span class="mf"&gt;2026&lt;/span&gt;&lt;span class="err"&gt;-&lt;/span&gt;&lt;span class="mf"&gt;06&lt;/span&gt;   &lt;span class="mf"&gt;7&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;May was the month everyone in the room described as "the team is struggling with the last stretch". It was the month the scope grew the most. Nobody lied. The growth simply had no line anywhere, so the only visible variable was the date.&lt;/p&gt;

&lt;h2&gt;
  
  
  The second number is the one that hurts
&lt;/h2&gt;

&lt;p&gt;Work arriving late is normal. On a site the owner changes his mind constantly: he moves the warehouse layout after the foundation is poured, raises the clear height, adds a crane bay. There is a path for it, and it is tedious on purpose, because price and date move together with the request.&lt;/p&gt;

&lt;p&gt;So I asked the tracker the second question: of those 41, how many ever got a revised date written down anywhere?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="nt"&gt;--arg&lt;/span&gt; freeze &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$FREEZE&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="s1"&gt;'.[] | select(.createdAt &amp;gt; $freeze) | .number'&lt;/span&gt; issues.json |
&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; n&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;hits&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;gh issue view &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--json&lt;/span&gt; body,comments &lt;span class="se"&gt;\&lt;/span&gt;
    &lt;span class="nt"&gt;--jq&lt;/span&gt; &lt;span class="s1"&gt;'[.body] + [.comments[].body] | join("\n")'&lt;/span&gt; |
    &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-Eic&lt;/span&gt; &lt;span class="s1"&gt;'estimate|revised date|new deadline|moves the date'&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="o"&gt;[&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$hits&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;-gt&lt;/span&gt; 0 &lt;span class="o"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;3
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three out of 41. Thirty eight work items entered the milestone through a conversation, and the schedule they landed on was still the one negotiated before they existed. That is the difference between a site that runs 20% long and a project that reads as a failure. One measured the drift along the way, the other showed up finished at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Turning the measurement into a CI check
&lt;/h2&gt;

&lt;p&gt;The fix I kept is boring and it lives in the repo, because process that lives in someone's memory is the same label that got applied twice. The workflow does not block the merge. It writes the fact on the PR, at the moment the work is being requested, while the decision is still cheap.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;scope-gate&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;pull_request&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;types&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;[&lt;/span&gt;&lt;span class="nv"&gt;opened&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;synchronize&lt;/span&gt;&lt;span class="pi"&gt;,&lt;/span&gt; &lt;span class="nv"&gt;reopened&lt;/span&gt;&lt;span class="pi"&gt;]&lt;/span&gt;

&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;flag-late-scope&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-latest&lt;/span&gt;
    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
      &lt;span class="pi"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Compare issue creation with the freeze date&lt;/span&gt;
        &lt;span class="na"&gt;env&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt;
          &lt;span class="na"&gt;GH_TOKEN&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ secrets.GITHUB_TOKEN }}&lt;/span&gt;
          &lt;span class="na"&gt;FREEZE&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-03-09T00:00:00Z"&lt;/span&gt;
          &lt;span class="na"&gt;PR&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.event.pull_request.number }}&lt;/span&gt;
          &lt;span class="na"&gt;REPO&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="s"&gt;${{ github.repository }}&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="pi"&gt;:&lt;/span&gt; &lt;span class="pi"&gt;|&lt;/span&gt;
          &lt;span class="s"&gt;issue=$(gh pr view "$PR" --repo "$REPO" \&lt;/span&gt;
            &lt;span class="s"&gt;--json closingIssuesReferences \&lt;/span&gt;
            &lt;span class="s"&gt;--jq '.closingIssuesReferences[0].number // empty')&lt;/span&gt;

          &lt;span class="s"&gt;if [ -z "$issue" ]; then echo "no linked issue"; exit 0; fi&lt;/span&gt;

          &lt;span class="s"&gt;created=$(gh issue view "$issue" --repo "$REPO" \&lt;/span&gt;
            &lt;span class="s"&gt;--json createdAt --jq .createdAt)&lt;/span&gt;
          &lt;span class="s"&gt;labels=$(gh issue view "$issue" --repo "$REPO" \&lt;/span&gt;
            &lt;span class="s"&gt;--json labels --jq '[.labels[].name] | join(",")')&lt;/span&gt;

          &lt;span class="s"&gt;if [[ "$created" &amp;gt; "$FREEZE" &amp;amp;&amp;amp; "$labels" != *"scope-change"* ]]; then&lt;/span&gt;
            &lt;span class="s"&gt;gh pr comment "$PR" --repo "$REPO" --body \&lt;/span&gt;
              &lt;span class="s"&gt;"Issue #$issue was created after the scope freeze and carries no scope-change label. Add the label with an effort range and a revised date, or move it out of this milestone."&lt;/span&gt;
          &lt;span class="s"&gt;fi&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The label came back to life once a robot asked for it instead of a person. Over the next two milestones the count went from 2 to 29, and the useful part was never the label itself: it was that the date discussion happened in the week the request arrived, not in the week of the deadline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where this falls apart
&lt;/h2&gt;

&lt;p&gt;If your team opens issues after the code is written, &lt;code&gt;createdAt&lt;/code&gt; measures nothing and you will get a clean report on a project that drifted anyway. Same for repos where one issue means "epic" and the next one means "typo", since counting items assumes items are roughly comparable in size, and mine were not. I looked at the 41 by hand to be sure the big ones were spread out, which is not a method, just a sanity check.&lt;/p&gt;

&lt;p&gt;I am also not convinced this pays for itself on a new product still hunting for its first customers. There the scope is supposed to move every week, and a bot commenting on every PR is noise with a YAML file attached.&lt;/p&gt;

&lt;p&gt;The part I do trust is the count. Take the last milestone that blew its date, run those two jq lines, and compare the number of items born after the estimate with the number that ever got a new date written down. If the second number is zero, the delay was never a surprise.&lt;/p&gt;

&lt;p&gt;How do you keep this visible in your repo? I am curious about people using milestones with explicit budget, or a bot that recomputes a forecast whenever an issue joins the milestone, because comparing dates in bash is the crudest version of this I could build.&lt;/p&gt;

&lt;p&gt;Originally published on the Revin blog: &lt;a href="https://revin.com.br/en/blog/why-it-cannot-deliver-like-construction" rel="noopener noreferrer"&gt;https://revin.com.br/en/blog/why-it-cannot-deliver-like-construction&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I ran git blame on the files that touch money: 87% of the commission logic had one author</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Wed, 26 Aug 2026 16:04:14 +0000</pubDate>
      <link>https://dev.to/revinsoftware/i-ran-git-blame-on-the-files-that-touch-money-87-of-the-commission-logic-had-one-author-3flj</link>
      <guid>https://dev.to/revinsoftware/i-ran-git-blame-on-the-files-that-touch-money-87-of-the-commission-logic-had-one-author-3flj</guid>
      <description>&lt;p&gt;Every planning meeting at a client I was helping ended the same way. Someone would pull a card about the commission rule, and someone else would say "better wait for Marina". Nobody found that odd. She always sorted it out.&lt;/p&gt;

&lt;p&gt;I wanted a number instead of a feeling, because "only Marina touches this" does not survive a budget conversation and a percentage does. So I spent an afternoon on the repository. Node, Postgres, about 64k lines, six years old, four developers on payroll and a fifth who left in 2023.&lt;/p&gt;

&lt;p&gt;The method is crude and it fits in one afternoon. Write down the rules that move money, map each one to the files that implement it, and ask git who owns those lines.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1: list the rules, then the paths
&lt;/h2&gt;

&lt;p&gt;This part is not automatable and that is fine. I sat with the product person and we listed ten rules that touch money: how the discount is calculated, when an order can be cancelled, what releases a partner payout, how tax is split on an invoice, and so on. Then a developer mapped each rule to the files that actually implement it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cat &lt;/span&gt;money-paths.txt
src/billing/commission.ts
src/billing/discount.ts
src/orders/cancellation.ts
src/payouts/partner-release.ts
src/invoices/tax-split.ts
...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Ten rules turned into 14 files. The mapping took longer than the script.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2: ask blame who wrote the surviving lines
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="k"&gt;while &lt;/span&gt;&lt;span class="nb"&gt;read&lt;/span&gt; &lt;span class="nt"&gt;-r&lt;/span&gt; f&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;do
  &lt;/span&gt;&lt;span class="nv"&gt;total&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git blame &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nt"&gt;-M&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;--line-porcelain&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; &lt;span class="s1"&gt;'^author '&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nv"&gt;top&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="si"&gt;$(&lt;/span&gt;git blame &lt;span class="nt"&gt;-w&lt;/span&gt; &lt;span class="nt"&gt;-M&lt;/span&gt; &lt;span class="nt"&gt;-C&lt;/span&gt; &lt;span class="nt"&gt;--line-porcelain&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
        | &lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="s1"&gt;'^author '&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt; | &lt;span class="nb"&gt;head&lt;/span&gt; &lt;span class="nt"&gt;-1&lt;/span&gt;&lt;span class="si"&gt;)&lt;/span&gt;
  &lt;span class="nb"&gt;printf&lt;/span&gt; &lt;span class="s1"&gt;'%-34s %4s lines %s\n'&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$f&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$total&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$top&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;done&lt;/span&gt; &amp;lt; money-paths.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output, names changed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;src/billing/commission.ts         412 lines  358 author Marina
src/billing/discount.ts           287 lines  169 author Marina
src/orders/cancellation.ts        233 lines  201 author Marina
src/payouts/partner-release.ts    191 lines   96 author Rafael
src/invoices/tax-split.ts         164 lines  102 author Marina
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The commission file came back at 87% single author. Four of the ten rules had the same name above 80%. That is the whole finding, and it took an afternoon.&lt;/p&gt;

&lt;p&gt;The flags matter more than the loop. &lt;code&gt;-w&lt;/code&gt; ignores whitespace changes, &lt;code&gt;-M&lt;/code&gt; follows lines moved inside a file, &lt;code&gt;-C&lt;/code&gt; follows lines copied from other files. Without them the number is a lie, and I will get to how badly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3: check who guards the door
&lt;/h2&gt;

&lt;p&gt;Authorship is half the story. The other half is review, because a rule with one author and three habitual reviewers is much less concentrated than the blame output suggests.&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;--state&lt;/span&gt; merged &lt;span class="nt"&gt;--limit&lt;/span&gt; 300 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--search&lt;/span&gt; &lt;span class="s2"&gt;"billing/commission in:path"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--json&lt;/span&gt; number,reviews &lt;span class="se"&gt;\&lt;/span&gt;
| jq &lt;span class="nt"&gt;-r&lt;/span&gt; &lt;span class="s1"&gt;'.[].reviews[].author.login'&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
| &lt;span class="nb"&gt;sort&lt;/span&gt; | &lt;span class="nb"&gt;uniq&lt;/span&gt; &lt;span class="nt"&gt;-c&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-rn&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;  19 marina
   3 rafael
   1 caio
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Nineteen of the 23 merged pull requests touching that path were approved by the same person who wrote it. So the review was not spreading the knowledge, it was confirming it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What did not work
&lt;/h2&gt;

&lt;p&gt;Three attempts died before the one above.&lt;/p&gt;

&lt;p&gt;First I ran &lt;code&gt;git shortlog -sn -- &amp;lt;file&amp;gt;&lt;/code&gt;, counting commits per author. It ranked a developer first on the discount file because he had done a dependency bump that reformatted imports. Commit count measures traffic, not ownership.&lt;/p&gt;

&lt;p&gt;Then I ran blame without &lt;code&gt;-w -M -C&lt;/code&gt;. A Prettier rollout in 2023 rewrote almost every line in the repository, and the output cheerfully told me that one engineer owned 71% of the entire business logic. He had joined four months earlier. Any blame based metric that ignores whitespace commits will hand you that kind of nonsense with a straight face.&lt;/p&gt;

&lt;p&gt;Last, I tried correlating with cyclomatic complexity, because that is what most answers on Stack Exchange recommend when someone asks how to quantify technical debt. The most complex file in the repository had four authors and nobody was afraid of it. The file that froze the planning meeting was 233 lines of plain conditionals. Complexity told me where the code was ugly. It said nothing about where the company was exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the ugly code is not the debt
&lt;/h2&gt;

&lt;p&gt;In systems that land on my desk after the previous vendor walked away, the thing that eats the first month is rarely the chained slow query or the test suite without a single assertion. It is figuring out why a strange conditional exists in the order service. That conditional is a commercial agreement somebody closed on the phone in 2019.&lt;/p&gt;

&lt;p&gt;The code was the only living record of the rule. Living records read slowly, and the person who could read it fast changed jobs.&lt;/p&gt;

&lt;p&gt;Construction has a name for the missing artifact. There is the design, drawn before anything is poured, and the as built, the drawing of how the structure ended up after every decision made on site, including the beam that moved because the soil did not cooperate. Skip the as built and you find the deviation by opening a wall with the building already occupied. In software the wall is invisible and the person who knew where it was is somewhere else.&lt;/p&gt;

&lt;p&gt;There is a cheap test for whether your docs are as built or decoration. Give a small change on that rule to someone who has never touched it, with the document and nothing else. If she opens Slack in twenty minutes, you have a file rather than a document.&lt;/p&gt;

&lt;h2&gt;
  
  
  What actually moved the number
&lt;/h2&gt;

&lt;p&gt;A documentation sprint fixes little and rots fast. Rotation moves the blame output, in three habits:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Every task on a concentrated rule ships as a pair, with the person who does not know it on the keyboard and the person who does only answering questions.&lt;/li&gt;
&lt;li&gt;On call rotates through everyone, with the author of the rule in second line rather than first, because on call is the only moment nobody can postpone the question.&lt;/li&gt;
&lt;li&gt;The rule owner writes half a page of why, including the options that were thrown out, and the page counts as done only after someone else changed the rule reading nothing but it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is slower. A pair delivers something like 20% to 30% slower in the first weeks and the team will say so out loud. It is the price of pulling one name off four lines of that list.&lt;/p&gt;

&lt;p&gt;Where the advice is bad: if you are three people still hunting for customers, concentration is an advantage. Rotation eats the week and your real problem is whether anyone pays. The math flips when revenue starts depending on that system, and it flips without warning.&lt;/p&gt;

&lt;p&gt;I re-ran the script on those paths eleven weeks later. Commission went from 87% to 61%, which is not great and is a lot better than a wiki nobody opened.&lt;/p&gt;

&lt;p&gt;How do you measure this where you work? I have seen bus factor plugins, CODEOWNERS coverage reports and pure gut feeling in planning, and I am honestly not sure the plugins beat the afternoon with blame. If you have a metric that survived contact with a real repository, I want to read it.&lt;/p&gt;

&lt;p&gt;Originally published on the Revin blog: &lt;a href="https://revin.com.br/en/blog/cognitive-debt-technical-debt-outside-code" rel="noopener noreferrer"&gt;https://revin.com.br/en/blog/cognitive-debt-technical-debt-outside-code&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>PHPCompatibility flagged 217 errors in a PHP 5.6 app. The one that cost money wasn't in the report</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Mon, 24 Aug 2026 14:08:34 +0000</pubDate>
      <link>https://dev.to/revinsoftware/phpcompatibility-flagged-217-errors-in-a-php-56-app-the-one-that-cost-money-wasnt-in-the-report-ieh</link>
      <guid>https://dev.to/revinsoftware/phpcompatibility-flagged-217-errors-in-a-php-56-app-the-one-that-cost-money-wasnt-in-the-report-ieh</guid>
      <description>&lt;p&gt;Last month someone handed me read access to a PHP 5.6 codebase and asked whether going to PHP 8 was the same size of job as dropping Laravel on top of what already existed. Roughly 90k lines, procedural, a homemade router, MySQL 5.7 underneath, running order capture and shipping rules for a distributor. Three developers in its history, none of them still at the company.&lt;/p&gt;

&lt;p&gt;The team had been arguing about it for two weeks with zero numbers on the table. Instead of joining the argument I ran a scanner, and then I did the boring thing nobody wants to do first: I froze the current behavior of the money flows before touching the version.&lt;/p&gt;

&lt;p&gt;Good thing, because the change that would have cost real money never showed up in the scanner report.&lt;/p&gt;

&lt;h2&gt;
  
  
  The linter counts the damage in about half an hour
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer require &lt;span class="nt"&gt;--dev&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  squizlabs/php_codesniffer &lt;span class="se"&gt;\&lt;/span&gt;
  phpcompatibility/php-compatibility

vendor/bin/phpcs &lt;span class="nt"&gt;-p&lt;/span&gt; ./src &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--standard&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;PHPCompatibility &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--runtime-set&lt;/span&gt; testVersion 8.2 &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--report&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;summary
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output, trimmed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;PHP CODE SNIFFER REPORT SUMMARY
--------------------------------------------------------------
FILE                                        ERRORS  WARNINGS
--------------------------------------------------------------
src/legacy/order_functions.php                  58        11
src/legacy/db.php                               41         3
src/legacy/mailer.php                           22         9
src/shipping/tier.php                            9         2
... (7 more files)
--------------------------------------------------------------
A TOTAL OF 217 ERRORS AND 89 WARNINGS WERE FOUND IN 11 FILES
--------------------------------------------------------------
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;217 errors across 11 files, in a codebase that everybody in the room described as "unfixable". Almost all of it mechanical: &lt;code&gt;each()&lt;/code&gt; removed, &lt;code&gt;create_function()&lt;/code&gt; gone, &lt;code&gt;mysql_*&lt;/code&gt; calls that already died back in 7.0, arguments passed by reference where that is no longer allowed. Rector cleared a large slice of it unattended:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;vendor/bin/rector process src &lt;span class="nt"&gt;--set&lt;/span&gt; php82 &lt;span class="nt"&gt;--dry-run&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;What was left after the dry run was a long afternoon of manual edits. Annoying, not a quarter of work.&lt;/p&gt;

&lt;h2&gt;
  
  
  The change that does not raise an error
&lt;/h2&gt;

&lt;p&gt;Loose comparison between a string and a number changed in PHP 8. On 5.6, &lt;code&gt;0 == "abc"&lt;/code&gt; returned true. On 8, it returns false. Nothing throws, no log line appears, the &lt;code&gt;if&lt;/code&gt; simply picks the other branch.&lt;/p&gt;

&lt;p&gt;In this codebase it lived inside a &lt;code&gt;switch&lt;/code&gt;, which is loose comparison wearing a costume:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="k"&gt;function&lt;/span&gt; &lt;span class="n"&gt;shippingTier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;switch&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;            &lt;span class="c1"&gt;// matches ANY non-numeric string on 5.6&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'free'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;case&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'flat'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s1"&gt;'standard'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;echo&lt;/span&gt; &lt;span class="nf"&gt;shippingTier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'EXPRESS'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="c1"&gt;// PHP 5.6 -&amp;gt; free&lt;/span&gt;
&lt;span class="c1"&gt;// PHP 8.2 -&amp;gt; standard&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same with &lt;code&gt;in_array(0, ['EXPRESS', 'ECONOMY'])&lt;/code&gt;, true on 5.6 and false on 8. A business rule flips and nobody signs off on it. The customer finds out from an invoice total three weeks later.&lt;/p&gt;

&lt;p&gt;Grep does not save you here. I tried: searching for &lt;code&gt;==&lt;/code&gt; in 90k lines returns thousands of hits, and reading them one by one is how you convince yourself you read them all.&lt;/p&gt;

&lt;h2&gt;
  
  
  Freeze the behavior, then bump the version
&lt;/h2&gt;

&lt;p&gt;What worked was characterization tests over the four flows that generate revenue. Not unit tests of what the code should do. A recording of what it does today, ugly parts included.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight php"&gt;&lt;code&gt;&lt;span class="c1"&gt;// tests/Characterization/ShippingTierTest.php&lt;/span&gt;
&lt;span class="nv"&gt;$inputs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s1"&gt;'EXPRESS'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'ECONOMY'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;''&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'0.0'&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="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'1abc'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="nv"&gt;$fh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;fopen&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;__DIR__&lt;/span&gt; &lt;span class="mf"&gt;.&lt;/span&gt; &lt;span class="s1"&gt;'/golden.txt'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'w'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;foreach&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$inputs&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nv"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nb"&gt;fwrite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fh&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;sprintf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"%-10s =&amp;gt; %s&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nb"&gt;var_export&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="nf"&gt;shippingTier&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$in&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="nb"&gt;fclose&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nv"&gt;$fh&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run it on the old runtime, keep the file, run it on the new one, diff:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;php5.6 tests/Characterization/ShippingTierTest.php &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv &lt;/span&gt;tests/Characterization/golden.txt golden-5.6.txt
php8.2 tests/Characterization/ShippingTierTest.php &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;mv &lt;/span&gt;tests/Characterization/golden.txt golden-8.2.txt
diff golden-5.6.txt golden-8.2.txt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;1c1
&lt;/span&gt;&lt;span class="gd"&gt;&amp;lt; 'EXPRESS'  =&amp;gt; free
&lt;/span&gt;&lt;span class="p"&gt;---
&lt;/span&gt;&lt;span class="gi"&gt;&amp;gt; 'EXPRESS'  =&amp;gt; standard
&lt;/span&gt;&lt;span class="p"&gt;2c2
&lt;/span&gt;&lt;span class="gd"&gt;&amp;lt; 'ECONOMY'  =&amp;gt; free
&lt;/span&gt;&lt;span class="p"&gt;---
&lt;/span&gt;&lt;span class="gi"&gt;&amp;gt; 'ECONOMY'  =&amp;gt; standard
&lt;/span&gt;&lt;span class="p"&gt;5c5
&lt;/span&gt;&lt;span class="gd"&gt;&amp;lt; '1abc'     =&amp;gt; flat
&lt;/span&gt;&lt;span class="p"&gt;---
&lt;/span&gt;&lt;span class="gi"&gt;&amp;gt; '1abc'     =&amp;gt; flat
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Three lines of diff on the flow that decides shipping cost. That is the whole return on the exercise. Across the four money flows I ended up with 3 behavior changes: two harmless, one that would have handed free shipping to nobody who had it before, or the reverse, depending on which way the data leaned that month.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I tried that did not work
&lt;/h2&gt;

&lt;p&gt;First attempt was to lean on the existing suite. The repo reported 34% coverage, which sounded like something. Opening the tests, a good chunk of them called the method and asserted nothing at all, no &lt;code&gt;assert*&lt;/code&gt; anywhere in the body. The metric existed, the guarantee did not. Coverage told me nothing about whether the version bump changed an outcome.&lt;/p&gt;

&lt;p&gt;Second attempt was worse and I am glad it was a branch. I ran a sweep turning &lt;code&gt;==&lt;/code&gt; into &lt;code&gt;===&lt;/code&gt; in the shipping and pricing files, on the theory that strict is safer. It is safer in a language where types are stable, and this app reads everything from &lt;code&gt;mysqli&lt;/code&gt; in the old procedural style, which hands back numeric columns as strings. Half the id comparisons started returning false. Reverted in twenty minutes. Strict comparison is a refactor with its own test bill, not a migration step you sneak in.&lt;/p&gt;

&lt;h2&gt;
  
  
  Then check what nobody maintains anymore
&lt;/h2&gt;

&lt;p&gt;Half a day of work and the answer changes the plan more than any framework debate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;composer outdated &lt;span class="nt"&gt;--direct&lt;/span&gt;
composer audit
php &lt;span class="nt"&gt;-m&lt;/span&gt; | &lt;span class="nb"&gt;sort&lt;/span&gt;   &lt;span class="c"&gt;# native extensions the server actually loads&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Watch for &lt;code&gt;mcrypt&lt;/code&gt;, which left core in 7.2 and is still glued to homegrown crypto in plenty of shops. If every direct dependency has an 8-compatible release, even one that needs work, the upgrade path is short. If the system is welded to a framework that stopped shipping fixes years ago, part of it gets rewritten either way, and now you know which part: the entry layer, not ten years of business rules.&lt;/p&gt;

&lt;p&gt;One detail that shortens meetings: 5.6 has been out of security support since the end of 2018. That does not choose between upgrading and rewriting. It removes the option everybody secretly prefers, which is looking at this next year.&lt;/p&gt;

&lt;h2&gt;
  
  
  The order that ended up working
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Characterization tests over the revenue flows, on the old runtime, golden files committed.&lt;/li&gt;
&lt;li&gt;Rector plus manual cleanup for the 217 errors, then diff the golden files and explain every line that moved.&lt;/li&gt;
&lt;li&gt;Housekeeping without changing the shape of the thing: Composer with PSR-4, a single front controller, config out of the code, real logging.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Only after that does standing a framework beside it on the same database make sense, one route at a time, new endpoints born there and old checkout left alone until tests can back the move. The system runs split for a while and that bothers everybody who likes clean things. It is also reversible on any given day, which a nine-month rewrite is not.&lt;/p&gt;

&lt;p&gt;Measured on this one: the scan took under an hour, the characterization tests took about two days, the actual upgrade landed in a bit over three weeks. The rewrite proposal on the table had been sized at a quarter, and every rewrite I have watched from close up ran past its number.&lt;/p&gt;

&lt;p&gt;The part I still do not have a clean answer for: flows that only prove themselves against a third party. Billing, tax invoices, the payment gateway. You cannot golden-file a webhook that only fires when a real customer pays. How do you snapshot behavior on those before a version jump? Recorded HTTP fixtures, a sandbox, or do you just ship it and watch the logs?&lt;/p&gt;




&lt;p&gt;Originally published on the Revin blog: &lt;a href="https://revin.com.br/en/blog/php-5-6-to-php-8-or-rewrite" rel="noopener noreferrer"&gt;https://revin.com.br/en/blog/php-5-6-to-php-8-or-rewrite&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I pointed an AI reviewer at a 2019 order service: 63 comments, and none of them was the IDOR</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Fri, 21 Aug 2026 12:55:09 +0000</pubDate>
      <link>https://dev.to/revinsoftware/i-pointed-an-ai-reviewer-at-a-2019-order-service-63-comments-and-none-of-them-was-the-idor-4n9o</link>
      <guid>https://dev.to/revinsoftware/i-pointed-an-ai-reviewer-at-a-2019-order-service-63-comments-and-none-of-them-was-the-idor-4n9o</guid>
      <description>&lt;p&gt;Last month I got read access to an order service that had been running since 2019. Node, Express, Postgres, around 64k lines, three developers in its history and none of them still at the company. The team wanted a second opinion before a batch of changes to the checkout flow.&lt;/p&gt;

&lt;p&gt;I ran an AI reviewer over the branch first. Half curiosity, half because everybody has been arguing about whether AI code review is a bubble, and I wanted a number of my own instead of a take.&lt;/p&gt;

&lt;p&gt;It came back with 63 comments across 12 files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sorting the 63
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;38 were naming, formatting, or "consider extracting this into a helper"&lt;/li&gt;
&lt;li&gt;11 were missing null checks, and 9 of those could not happen, because the value came from a NOT NULL column&lt;/li&gt;
&lt;li&gt;8 suggested wrapping code in a try/catch that was already inside a try/catch&lt;/li&gt;
&lt;li&gt;4 were about actual behavior, and 2 of those I would have written myself: a date compared as a string, and a retry loop with no ceiling&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So something around 3% of the output was worth a human's attention. That ratio alone is not damning. Linters have terrible ratios too and nobody cares, because a linter costs nothing to skim.&lt;/p&gt;

&lt;p&gt;The part that bothered me is what it stayed quiet about.&lt;/p&gt;

&lt;h2&gt;
  
  
  The comment it did not write
&lt;/h2&gt;

&lt;p&gt;Here is the endpoint, cleaned up and renamed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/routes/orders.js&lt;/span&gt;
&lt;span class="nx"&gt;router&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/orders/:id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;requireAuth&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;oneOrNone&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;select * from orders where id = $1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;params&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
  &lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;status&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;error&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not_found&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;);&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;requireAuth&lt;/code&gt; resolves the session and puts &lt;code&gt;req.user&lt;/code&gt; in place. It never asks whether that order belongs to the caller. The ids are sequential integers. Three curl calls with a valid cookie and a different number and you are reading another customer's address, items and total.&lt;/p&gt;

&lt;p&gt;The AI's comment on that exact file was: &lt;em&gt;"Consider extracting the 404 response into a shared helper for consistency."&lt;/em&gt; Accurate. Useless.&lt;/p&gt;

&lt;p&gt;I checked how widespread it was with ripgrep before doing anything else:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;rg &lt;span class="nt"&gt;-n&lt;/span&gt; &lt;span class="s2"&gt;"where id = &lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;1"&lt;/span&gt; &lt;span class="nt"&gt;--glob&lt;/span&gt; &lt;span class="s1"&gt;'!test/**'&lt;/span&gt; src/
src/routes/orders.js:41:    &lt;span class="s1"&gt;'select * from orders where id = $1'&lt;/span&gt;,
src/routes/invoices.js:88:   &lt;span class="s1"&gt;'select * from invoices where id = $1'&lt;/span&gt;,
src/jobs/reconcile.js:23:    &lt;span class="s1"&gt;'select * from orders where id = $1'&lt;/span&gt;,
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two of the three were reachable from the browser. The third runs in a job with no request context, so it is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a diff reviewer cannot see this
&lt;/h2&gt;

&lt;p&gt;The reviewer reasons about lines that exist. A broken authorization check is an absence. There is no bad line to point at, and the thing that makes it dangerous lives somewhere else entirely: in the fact that ids are sequential, in what &lt;code&gt;requireAuth&lt;/code&gt; chose not to do, in a middleware file the branch never touched.&lt;/p&gt;

&lt;p&gt;Same story with the two other findings from that week. A query sitting inside a &lt;code&gt;for&lt;/code&gt; loop that only hurts when a customer has more than a handful of shipments. A &lt;code&gt;/health&lt;/code&gt; route that returns 200 without checking anything, which means the load balancer keeps sending traffic to a process that lost its database pool. Both are invisible in a diff and obvious when you run the system.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix I tried that did not work
&lt;/h2&gt;

&lt;p&gt;My first move was the obvious one: give it more context. Whole file instead of hunk, then a repo map, then a system prompt that spelled it out, roughly "check every route for broken object level authorization, ids are sequential".&lt;/p&gt;

&lt;p&gt;Told explicitly what to hunt for, it found the orders endpoint. It also flagged 7 endpoints in total, and 5 of those were wrong. They loaded data through a repository function that already scopes by &lt;code&gt;customer_id&lt;/code&gt;, one call deeper than the route file. The model could not tell the difference between an endpoint with no ownership filter and an endpoint whose filter is two hops away.&lt;/p&gt;

&lt;p&gt;Which is the honest summary: it runs a checklist over text. It does not know who owns what in your system, and the prompt that makes it paranoid enough to catch the real one also makes it cry wolf five times.&lt;/p&gt;

&lt;h2&gt;
  
  
  What sits in CI now
&lt;/h2&gt;

&lt;p&gt;I stopped asking the reviewer for security opinions and wrote a test with two tenants:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;an order is not readable by another customer&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;alice&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;seedCustomer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;bob&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;seedCustomer&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;order&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;seedOrder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;bob&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;total&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1290&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`/orders/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Authorization&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nf"&gt;bearer&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;alice&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;404&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;404 and not 403, so the endpoint does not confirm that the id exists.&lt;/p&gt;

&lt;p&gt;One test only covers one route, and the point was the other 40. So there is a second script that walks the Express router stack and prints every path that never touches an owner filter:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;node scripts/audit-routes.js
GET   /orders/:id              no owner filter
GET   /invoices/:id            no owner filter
POST  /orders/:id/cancel       no owner filter
GET   /orders                  scoped &lt;span class="o"&gt;(&lt;/span&gt;customer_id&lt;span class="o"&gt;)&lt;/span&gt;
...
3 of 41 routes unscoped
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It is crude. It matches on the repository functions we consider safe, so a fourth way of loading an order would slip past it until someone adds it to the list. But it is deterministic, it runs in about 2 seconds, and it never invents a problem to look useful.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I land on the bubble argument
&lt;/h2&gt;

&lt;p&gt;I don't think these tools are worthless. Two of those 63 comments were real, and one of them was a bug I would probably have shipped.&lt;/p&gt;

&lt;p&gt;What I think is broken is measuring them by comments produced. Every comment spends attention, and attention runs out. After the twentieth "consider extracting", the human reviewer starts scrolling, and the one comment that mattered scrolls by with the rest. The tool got cheaper and moved the cost onto the person approving the merge.&lt;/p&gt;

&lt;p&gt;So the number I care about now is not how much it finds. It is how much noise a real finding has to survive.&lt;/p&gt;

&lt;p&gt;How are you drawing that line? Specifically, has anyone gotten an AI reviewer to reason about authorization across files without drowning in false positives, or did you also give up and write the boring two-tenant test?&lt;/p&gt;




&lt;p&gt;This audit came out of client work at Revin.&lt;/p&gt;

</description>
    </item>
    <item>
      <title>An agent wrote 14 tests for a module I knew was broken. Mutation score came back 41%</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Wed, 19 Aug 2026 13:14:13 +0000</pubDate>
      <link>https://dev.to/revinsoftware/an-agent-wrote-14-tests-for-a-module-i-knew-was-broken-mutation-score-came-back-41-157a</link>
      <guid>https://dev.to/revinsoftware/an-agent-wrote-14-tests-for-a-module-i-knew-was-broken-mutation-score-came-back-41-157a</guid>
      <description>&lt;p&gt;Last month I ran a small experiment on a checkout module I already knew was broken.&lt;/p&gt;

&lt;p&gt;The module applies tiered discounts to an order total. The bug is boring: the tier discount gets applied twice above a certain subtotal, and the coupon argument is read but never used. It survived two code reviews because the output still looks like money and the difference only shows up on bigger carts.&lt;/p&gt;

&lt;p&gt;I gave an agent read access to that one file and asked for a test suite. Nothing else in the prompt, no examples, no description of expected behavior. It came back with 14 tests. All green on the first run. Line coverage for the file went from nothing to 92%. The bug shipped anyway.&lt;/p&gt;

&lt;p&gt;Martin Fowler's site published a piece last week called "TDD inside the agent loop - theater or actual value?", and reading it is what pushed me to stop arguing from feeling and write down my own numbers. So here they are.&lt;/p&gt;

&lt;h2&gt;
  
  
  The file under test
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;TIERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&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="na"&gt;off&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="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;min&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="na"&gt;off&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mf"&gt;0.12&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;orderTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Item&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="nx"&gt;coupon&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="nx"&gt;Coupon&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;reduce&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;acc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;acc&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;price&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;qty&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;TIERS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;min&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;coupon&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;subtotal&lt;/span&gt; &lt;span class="o"&gt;&amp;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="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;tier&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;off&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="c1"&gt;// the tier lands a second time&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What the agent gave me
&lt;/h2&gt;

&lt;p&gt;Two of the 14 tests were fine. The rest looked like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;orderTotal&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;returns a number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nf"&gt;orderTotal&lt;/span&gt;&lt;span class="p"&gt;([{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}])).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;applies the 5% tier&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;orderTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mf"&gt;0.05&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;handles a coupon&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[{&lt;/span&gt; &lt;span class="na"&gt;price&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}];&lt;/span&gt;
    &lt;span class="nf"&gt;expect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;orderTotal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;X10&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})).&lt;/span&gt;&lt;span class="nf"&gt;toBe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mf"&gt;929.28&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Look at the last one. 929.28 is the double discount. The agent read the implementation, computed what the code currently does, and froze it as the expected value. The test is green because the bug is now documented as a requirement.&lt;/p&gt;

&lt;p&gt;This is the part that no coverage report will ever tell you. A test written from the implementation can only confirm the implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  The mutation run
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;npx stryker run &lt;span class="nt"&gt;--mutate&lt;/span&gt; &lt;span class="s1"&gt;'src/checkout/order-total.ts'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;-----------------|---------|----------|----------|-----------|---------|
File             | % score | # killed | # surviv | # timeout | # error |
-----------------|---------|----------|----------|-----------|---------|
order-total.ts   |   41.37 |       12 |       17 |         0 |       0 |
-----------------|---------|----------|----------|-----------|---------|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;92% of the lines executed, 41.37% of the mutants killed. Twelve dead, seventeen alive. Two of the survivors say the whole story:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight diff"&gt;&lt;code&gt;&lt;span class="p"&gt;Survived mutant #7 (ConditionalExpression)
&lt;/span&gt;&lt;span class="gd"&gt;-  if (coupon &amp;amp;&amp;amp; subtotal &amp;gt;= 1000) {
&lt;/span&gt;&lt;span class="gi"&gt;+  if (coupon &amp;amp;&amp;amp; true) {
&lt;/span&gt;&lt;span class="err"&gt;
&lt;/span&gt;&lt;span class="p"&gt;Survived mutant #12 (EqualityOperator)
&lt;/span&gt;&lt;span class="gd"&gt;-  TIERS.filter((t) =&amp;gt; subtotal &amp;gt;= t.min)
&lt;/span&gt;&lt;span class="gi"&gt;+  TIERS.filter((t) =&amp;gt; subtotal &amp;gt; t.min)
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mutant #12 flips &lt;code&gt;&amp;gt;=&lt;/code&gt; into &lt;code&gt;&amp;gt;&lt;/code&gt; and nothing in the suite notices, which means no test ever hits a subtotal of exactly 500 or exactly 1000. Fourteen tests and not one boundary. Mutant #7 removes the subtotal condition entirely and the suite stays green, because the coupon test asserts the broken output.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two things I tried that went nowhere
&lt;/h2&gt;

&lt;p&gt;First I did the obvious thing and asked for more. "Improve the test suite, target 95% coverage." It wrote six extra tests, coverage went to about 96%, mutation score moved to roughly 47%. More assertions on shape, more &lt;code&gt;toBeDefined&lt;/code&gt;, same blind spots. Raising the coverage gate rewarded exactly the behavior I was trying to kill.&lt;/p&gt;

&lt;p&gt;Then I put the theory in the prompt: "write boundary tests, this suite has weak mutation coverage." The agent produced tests that look like boundary tests, with subtotals of 499, 500 and 501, and it filled every expected value by running the current code in its head. Green again. The vocabulary changed, the epistemology did not.&lt;/p&gt;

&lt;h2&gt;
  
  
  What moved the number
&lt;/h2&gt;

&lt;p&gt;Two changes, both of them about what the agent is allowed to read.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Feed the surviving mutants back in as the task. Stryker writes a JSON report, so the loop becomes "here is mutant #12, write a test that kills it" instead of "write tests". Two rounds of that took the score from 41% to somewhere near 78%.&lt;/li&gt;
&lt;li&gt;Make the red step mandatory and machine-checked. The agent may not see the implementation when writing the test, and the test has to fail against the current code before anyone looks at it.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The second one is three lines in a pre-commit hook and it is the only part of this I would call TDD:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# a new test must be able to fail on the unfixed file&lt;/span&gt;
git stash push &lt;span class="nt"&gt;--&lt;/span&gt; src/checkout/order-total.ts
&lt;span class="k"&gt;if &lt;/span&gt;npx jest src/checkout/order-total.spec.ts &lt;span class="nt"&gt;--silent&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="k"&gt;then
  &lt;/span&gt;&lt;span class="nb"&gt;echo&lt;/span&gt; &lt;span class="s2"&gt;"test passes on the broken implementation - rejected"&lt;/span&gt;
  git stash pop &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;exit &lt;/span&gt;1
&lt;span class="k"&gt;fi
&lt;/span&gt;git stash pop
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Crude, and it only works when the fix and the test arrive in the same change. But it caught four tests in the following week that were green against code they were supposed to break.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cost, honestly
&lt;/h2&gt;

&lt;p&gt;Mutation runs are slow. That single file takes about 40 seconds, the full suite takes something close to 11 minutes on our runner, so it only runs on changed files in CI and on a nightly job for the payment paths. I have no idea whether the mutants-as-input loop holds up on a codebase with heavy mocking, because ours has fairly little of it. If your suite mocks the module under test, most mutants die for the wrong reason and the score lies in the other direction.&lt;/p&gt;

&lt;p&gt;What I stopped believing is the green board. An agent writing tests after reading the implementation produces a very convincing photograph of the bug you already have. Coverage measures which lines ran. Mutation measures whether anyone would have noticed if those lines were wrong, and the gap between 92 and 41 is where the money leaked.&lt;/p&gt;

&lt;p&gt;So, a real question: for those of you running agents inside a TDD loop, how do you stop the model from deriving the expected value from the code it is looking at? Hiding the implementation behind an interface helped a bit here, and I would like to hear what else works before I turn this into a rule for the whole repo.&lt;/p&gt;




&lt;p&gt;I write about this kind of thing on the Revin blog: &lt;a href="https://revin.com.br/en" rel="noopener noreferrer"&gt;https://revin.com.br/en&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>I inherited an undocumented codebase: 2.5 days to boot it, ~400 silent errors a day</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Fri, 14 Aug 2026 13:47:02 +0000</pubDate>
      <link>https://dev.to/revinsoftware/i-inherited-an-undocumented-codebase-25-days-to-boot-it-400-silent-errors-a-day-p8g</link>
      <guid>https://dev.to/revinsoftware/i-inherited-an-undocumented-codebase-25-days-to-boot-it-400-silent-errors-a-day-p8g</guid>
      <description>&lt;p&gt;Six weeks ago I got read access to a system nobody had documented. The dev who wrote most of it left in 2023. The README had three lines and two of them were wrong.&lt;/p&gt;

&lt;p&gt;My first instinct was the wrong one. I opened the repo and started reading, module by module, for two days. At the end I could describe the folder structure and nothing else. Reading code tells you what the code says. It says nothing about what actually runs, how often, and what breaks at 3am.&lt;/p&gt;

&lt;p&gt;So I stopped reading and ran three experiments instead, in this order: boot the system on a clean machine while logging every missing step, instrument what was already in production, and find the handful of paths where money moves. No features, no refactors, for two weeks.&lt;/p&gt;

&lt;p&gt;The Stack Exchange question "I've inherited 200,000 lines of spaghetti code, what now?" has been sitting there since 2012, past 200k views, 463 votes, 19 answers. Almost every answer argues about code quality. I couldn't join that argument yet, because I still didn't know what the system did.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 1: boot it on a clean container
&lt;/h2&gt;

&lt;p&gt;Rule I set for myself: no asking anyone, no copying files from a colleague's laptop. Empty container, the repo, and whatever documentation exists.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;git clone git@github.com:redacted/api.git &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;cd &lt;/span&gt;api
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;cp&lt;/span&gt; .env.example .env &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; docker compose up &lt;span class="nt"&gt;--build&lt;/span&gt;
api-1  | Error: connect ECONNREFUSED 127.0.0.1:6379
api-1  |   at TCPConnectWrap.afterConnect &lt;span class="o"&gt;[&lt;/span&gt;as oncomplete]
api-1  | &lt;span class="c"&gt;# REDIS_URL is read in src/queue/client.ts and is not in .env.example&lt;/span&gt;
api-1 exited with code 1
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every time it broke I appended a line to &lt;code&gt;BOOT.md&lt;/code&gt;: the error, what fixed it, how long it took. That file ended up with 14 undocumented steps and the whole thing took me 2.5 days.&lt;/p&gt;

&lt;p&gt;One grep was enough to prove the env problem was structural and not bad luck:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-rhoE&lt;/span&gt; &lt;span class="s2"&gt;"process&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;env&lt;/span&gt;&lt;span class="se"&gt;\.&lt;/span&gt;&lt;span class="s2"&gt;[A-Z_]+"&lt;/span&gt; src | &lt;span class="nb"&gt;sort&lt;/span&gt; &lt;span class="nt"&gt;-u&lt;/span&gt; | &lt;span class="nb"&gt;wc&lt;/span&gt; &lt;span class="nt"&gt;-l&lt;/span&gt;
23
&lt;span class="nv"&gt;$ &lt;/span&gt;&lt;span class="nb"&gt;grep&lt;/span&gt; &lt;span class="nt"&gt;-cE&lt;/span&gt; &lt;span class="s2"&gt;"^[A-Z_]+="&lt;/span&gt; .env.example
11
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Twelve variables the application reads and nobody wrote down. The rest of the list looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;the database schema only existed as a production dump someone pastes into a chat window&lt;/li&gt;
&lt;li&gt;the runtime was pinned to a three-year-old minor through an undeclared engine field&lt;/li&gt;
&lt;li&gt;one payment integration had no sandbox and pointed at the live endpoint from local&lt;/li&gt;
&lt;li&gt;the seed script assumed a tenant row that only exists in production&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;code&gt;BOOT.md&lt;/code&gt; became the first honest document the project had. In construction there's a drawing called as-built: the plan of what was actually erected, not the one that left the office. That's what a boot log is.&lt;/p&gt;

&lt;p&gt;The number I track now: time between a new dev getting access and seeing one request served locally. It went from 2.5 days to roughly 40 minutes once I committed the compose fixtures and a seed that doesn't need production.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 2: a week of production data beats a month of reading
&lt;/h2&gt;

&lt;p&gt;The system had a &lt;code&gt;/health&lt;/code&gt; endpoint answering 200 and a colourful panel wired to it. Neither measured anything that breaks. The metric existed, the guarantee didn't.&lt;/p&gt;

&lt;p&gt;I installed three things and stopped, on purpose, because none of them touch business rules:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// src/http/observability.ts&lt;/span&gt;
&lt;span class="nx"&gt;app&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;use&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;next&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;randomUUID&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHeader&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x-request-id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

  &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;finish&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hrtime&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;bigint&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="nx"&gt;e6&lt;/span&gt;
    &lt;span class="nx"&gt;logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;info&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;request_id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;route&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;route&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;path&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;unmatched&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;req&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;duration_ms&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="p"&gt;})&lt;/span&gt;

  &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Structured logs with a request id, error grouping in a capture tool, and response time per route at the edge. One deploy, one file, low blast radius.&lt;/p&gt;

&lt;p&gt;Seven days later the log aggregation said three routes out of 61 carried around 78% of the traffic. It also surfaced an error firing about 400 times a day inside a swallowed catch, which had never reached a single dashboard.&lt;/p&gt;

&lt;p&gt;The latency answer came from the database, not from the code I had been reading:&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;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;mean_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;avg_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;round&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;numeric&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&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;total_s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;70&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;query&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stat_statements&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;total_exec_time&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
&lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Top row: a &lt;code&gt;SELECT ... WHERE order_id = $1&lt;/code&gt; called 41,882 times in a day at 6.4ms each. Cheap query, called inside a loop, once per line item. Half the p95 of the checkout route lived there.&lt;/p&gt;

&lt;p&gt;What I tried and dropped: full distributed tracing in week one. Instrumenting every service boundary meant editing code I didn't understand yet and shipping a deploy I couldn't reason about. Edge timings plus a request id got me most of the answer from one middleware, and I added spans later, only on the paths that mattered.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 3: find the five paths where money moves
&lt;/h2&gt;

&lt;p&gt;This one has no code. I booked an hour with support, an hour with finance, and an hour with the person who has been in operations the longest. Three questions: what do customers call about, what jams at month-end close, and which spreadsheet exists today to work around the software.&lt;/p&gt;

&lt;p&gt;Five flows came out, and I mapped each one to concrete routes and jobs using the request ids from experiment 2:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;signup and login, because a customer who can't get in complains within minutes&lt;/li&gt;
&lt;li&gt;checkout, plus every payment call it fires underneath&lt;/li&gt;
&lt;li&gt;the nightly billing job, the oldest script in the repo and the one with no reprocessing&lt;/li&gt;
&lt;li&gt;outbound integration with the ERP, where a silent failure becomes a reconciliation mess a quarter later&lt;/li&gt;
&lt;li&gt;the report leadership opens on Monday, which decides whether the system is trusted&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Five paths with an owner and log evidence behind each one told me more than any diagram of 200k lines would have.&lt;/p&gt;

&lt;h2&gt;
  
  
  The 92% coverage that guarantees nothing
&lt;/h2&gt;

&lt;p&gt;I asked for the coverage number before looking at the suite. 92%. Then I opened the billing tests:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nf"&gt;it&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;generates invoices for active contracts&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;contracts&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;factory&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;contracts&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;active&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;billing&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;run&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;reference&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt;
  &lt;span class="c1"&gt;// no assertion&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It runs, it passes, it counts as covered lines. A test with no assertion is a line counter with good PR.&lt;/p&gt;

&lt;p&gt;So I ran mutation testing on that one module:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$ &lt;/span&gt;npx stryker run &lt;span class="nt"&gt;--mutate&lt;/span&gt; &lt;span class="s2"&gt;"src/billing/**/*.ts"&lt;/span&gt;
Ran 1.72 tests per mutant on average.
&lt;span class="nt"&gt;---------------&lt;/span&gt;|---------|----------|-----------|------------|
File           | % score | killed   | survived  | no coverage|
&lt;span class="nt"&gt;---------------&lt;/span&gt;|---------|----------|-----------|------------|
billing        |   41.18 |      44  |       63  |          0 |
&lt;span class="nt"&gt;---------------&lt;/span&gt;|---------|----------|-----------|------------|
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;92% coverage, 41% mutation score, 63 mutants alive in the code that issues invoices. Now the theatre had a receipt.&lt;/p&gt;

&lt;p&gt;I didn't try to write the whole suite in two weeks. I wrote characterisation tests on the five revenue paths, pinning current behaviour exactly as it is today, wrong parts included. That's a safety net and it isn't quality yet. Quality comes after you know what you can touch without dropping revenue.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rewrite I didn't do
&lt;/h2&gt;

&lt;p&gt;Around day 4 the sentence shows up: this is unmaintainable, we should rewrite it. I keep saying no for a practical reason. The ugly code is the only living documentation of the business rules. The weird &lt;code&gt;if&lt;/code&gt; in the middle of the order service turned out to be a contract signed with a large customer in 2019, and nobody on the current team knew it existed. A rewrite throws away the answer together with the question.&lt;/p&gt;

&lt;p&gt;Where that stops being true: a runtime with no security support, a critical dependency unpatched for years, a stack you can't hire for anymore. Then the maths flips, and even then I'd go piece by piece with the old system running beside me as the oracle. And I honestly don't know that any of this scales down to a 3,000-line app with forty users. One afternoon probably covers that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where day 14 landed
&lt;/h2&gt;

&lt;p&gt;Boot time from 2.5 days to about 40 minutes. One error at ~400/day found and fixed. One N+1 removed from checkout. Mutation score measured on the module that bills customers. Characterisation tests on five flows. Zero features, zero refactors.&lt;/p&gt;

&lt;p&gt;The part I'm least sure about is experiment 3. Sitting with support and finance worked, but it's slow and it depends on those people having time for me. Has anyone found a faster way to identify the revenue paths straight from telemetry? And do you instrument first or read first when you land on a codebase nobody can explain?&lt;/p&gt;




&lt;p&gt;Originally published on the Revin blog: &lt;a href="https://revin.com.br/en/blog/inherited-spaghetti-code-first-two-weeks" rel="noopener noreferrer"&gt;https://revin.com.br/en/blog/inherited-spaghetti-code-first-two-weeks&lt;/a&gt;&lt;/p&gt;

</description>
    </item>
    <item>
      <title>The code that passes the pitch and fails due diligence</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Wed, 12 Aug 2026 12:56:57 +0000</pubDate>
      <link>https://dev.to/revinsoftware/the-code-that-passes-the-pitch-and-fails-due-diligence-5fll</link>
      <guid>https://dev.to/revinsoftware/the-code-that-passes-the-pitch-and-fails-due-diligence-5fll</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://revin.com.br/en/blog/the-code-that-passes-the-pitch-and-fails-diligence" rel="noopener noreferrer"&gt;Revin blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For months I kept hearing the same scene told from different angles. A founder lands the round's narrative, the investor likes it, a term sheet shows up. Then technical diligence starts, someone from outside opens the repository, and the number on the page starts to shrink.&lt;/p&gt;

&lt;p&gt;In May I talked to the founder of a healthtech company who had just been through it. Series A on the table, an eager lead, and a consultant the fund had hired to read two and a half years of product. The report came back at thirteen pages. The part that stung wasn't a bug: it was that nobody on the cap table could say who legally owned the code written by a studio she'd hired in year one and let go later. The contract was boilerplate. So was the IP clause.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Her product worked fine.&lt;/strong&gt; What failed was everything around it: the contract, the ownership, a history nobody had read with a buyer's eyes.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2bojgttr9j30rj4eaj5.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fo2bojgttr9j30rj4eaj5.jpg" alt="Two people going over a report, pen on the chart: diligence reads what the pitch left out." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Two people going over a report, pen on the chart: diligence reads what the pitch left out.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I've written about engineering teams for a while, and this is the gap that catches my eye the most. A pitch is a sales object: it shows the traction, the line going up, the demo that works. Diligence is the opposite of a pitch. It doesn't want the good story, it wants what sits underneath. And what sits underneath is rarely built with the day in mind when someone from outside lifts the lid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Diligence opens what the pitch keeps shut
&lt;/h2&gt;

&lt;p&gt;Technical diligence is not bug hunting. A serious consultant spends three to five days looking at a set of things the founder almost never measured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Intellectual property&lt;/strong&gt;: who signed what, and whether third-party code actually became yours.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Knowledge concentration&lt;/strong&gt;: how many people understand the system, and what happens if the main one leaves.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Basic security&lt;/strong&gt;: a secret committed to the repo, a dependency left unpatched, a door left open.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Ability to ship&lt;/strong&gt;: how long it takes from a commit to production, and how many hands have to get involved.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these show up in a demo. All of them show up in a report. Which is why diligence is usually the first time a founder sees their own product through the eyes of the person about to pay for it.&lt;/p&gt;

&lt;h2&gt;
  
  
  "We'll clean it up before the round" doesn't survive contact
&lt;/h2&gt;

&lt;p&gt;The most common reaction, when I raise this in conversation, is to defer. "When the round gets close, we'll get organized." It feels like the natural order of things. Except diligence won't wait for you to be ready.&lt;/p&gt;

&lt;p&gt;You can't document two years of decisions in three weeks. Rewriting an IP contract with a vendor who already walked away is a slow negotiation, sometimes with someone who no longer picks up. Testing code that never had tests, written by people no longer on the team, is archaeology. Diligence lands in the worst possible window: when you have the least time and the most to lose. Show up improvising and the report becomes the other side's discount argument.&lt;/p&gt;

&lt;p&gt;The counter has no shine to it, and it works: a team that operates, from day one, as if the lid could be lifted at any moment. The squads we work with at Revin run that way by default, with a round on the horizon or none at all. Code that passes diligence is the same code that doesn't break in production, and the discipline behind one is the discipline behind the other.&lt;/p&gt;

&lt;p&gt;If you suspect your code would not survive an outside read, a Diagnostic Sprint lifts the lid before the investor does. &lt;a href="https://revin.com.br/en/diagnostic-sprint" rel="noopener noreferrer"&gt;Worth a look.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What usually shows up in the report
&lt;/h2&gt;

&lt;p&gt;I've read more diligence reports than I'd like, and a few patterns keep repeating. A production secret committed in plain text somewhere in the Git history, where it never truly disappears. One person holding the core of the system in their head, with nothing written down. Libraries two or three major versions behind, carrying a known and public vulnerability. A deploy that hinges on a manual step living in someone's memory.&lt;/p&gt;

&lt;p&gt;Across the projects that reached me for a second opinion this past year, something like two-thirds had at least one secret exposed in the repository. That number is probably skewed, I'll admit: nobody calls me to talk about the diligence that went well. But even cut in half, it's too high for a problem that costs a day to fix and a whole round to discover late.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumq5897gotuatkjpkcl7.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fumq5897gotuatkjpkcl7.jpg" alt="A team around one laptop: code that passes an outside read is built by people who expected one." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A team around one laptop: code that passes an outside read is built by people who expected one.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The report doesn't ask who wrote it
&lt;/h2&gt;

&lt;p&gt;Here's a misunderstanding that trips founders up. A bad diligence result is not a synonym for outsourced engineering. I've seen in-house teams leave a repository in a state that would embarrass any vendor, and I've seen an external squad hand over code the fund's consultant praised in writing. The question that matters is only one: was the code written with discipline, or was it a patch job?&lt;/p&gt;

&lt;p&gt;A patch job has a recognizable signature: the freelancer who showed up for three months and vanished, the studio that shipped the MVP and handed back a boilerplate contract, the agency that billed by the hour and left no test behind. Each one solved the week's problem and none thought about the report coming two years later. A managed senior squad does think, because the same discipline that makes a test pass is the one that gets an IP clause signed and keeps a secret out of the repo.&lt;/p&gt;

&lt;p&gt;Who actually owns the code, by the way, deserves its own piece, and &lt;a href="https://revin.com.br/en/blog/who-actually-owns-your-code" rel="noopener noreferrer"&gt;there is one here.&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Back to that founder
&lt;/h2&gt;

&lt;p&gt;The healthtech closed its round, but smaller than it was worth, with part of the money earmarked to fix what diligence had flagged. She left me with a line I keep repeating: "I paid for the same code twice: once to write it, once to prove it was mine."&lt;/p&gt;

&lt;p&gt;I'm not writing this to scare anyone raising capital. I'm writing it because diligence is just the moment the bill arrives; the spending happened much earlier, in the choice of who built the thing and how. If you want the outside read to be a stamp instead of a shock, the time to deal with it is now, long before the term sheet.&lt;/p&gt;

&lt;p&gt;Want an honest read on the real state of your code before a fund does theirs? &lt;a href="https://revin.com.br/en/schedule-a-call" rel="noopener noreferrer"&gt;Talk to Revin.&lt;/a&gt;&lt;/p&gt;




&lt;p&gt;We write about software engineering, managed squads and technical decisions every week at &lt;a href="https://revin.com.br/en/blog?utm_source=devto&amp;amp;utm_medium=syndication" rel="noopener noreferrer"&gt;revin.com.br&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>founders</category>
      <category>softwaredevelopment</category>
      <category>startup</category>
    </item>
    <item>
      <title>I changed one number in the URL and saw another customer's orders</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Mon, 10 Aug 2026 13:11:53 +0000</pubDate>
      <link>https://dev.to/revinsoftware/i-changed-one-number-in-the-url-and-saw-another-customers-orders-kgo</link>
      <guid>https://dev.to/revinsoftware/i-changed-one-number-in-the-url-and-saw-another-customers-orders-kgo</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://revin.com.br/en/blog/i-changed-one-number-in-the-url-and-saw-another-customers-orders" rel="noopener noreferrer"&gt;Revin blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The client came in through a referral, asking for a second opinion. The previous shop had shipped his app about four months earlier. It was live, taking orders, and he just wanted to know whether the house was in order before signing the next phase with the same crew.&lt;/p&gt;

&lt;p&gt;I opened the dashboard on a test account and looked at the address bar on an order page. It ended in something like /orders/10432. I changed the number to 10431 and hit enter. Up came another company's order on his platform: name, total, line items, contact email. No error, no warning. A clean 200, as if it were mine to read.&lt;/p&gt;

&lt;p&gt;If you pay a vendor to build your product and nobody has ever shown you &lt;strong&gt;how one customer's access is kept separate from another's&lt;/strong&gt;, this one is for you.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7eud3juh7ys5wni6tgog.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7eud3juh7ys5wni6tgog.jpg" alt="A second-opinion finding almost never shows up in the demo. It shows up when someone sits down and tests what the app does with an order that isn't theirs." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A second-opinion finding almost never shows up in the demo. It shows up when someone sits down and tests what the app does with an order that isn't theirs.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Being logged in is not the same as being allowed
&lt;/h2&gt;

&lt;p&gt;The bug has a name, and it has topped application-security lists for years: broken access control. In plain terms, the system checked who you are at login and stopped there. When it handed over that specific order, it never asked again whether the order was yours.&lt;/p&gt;

&lt;p&gt;Two different questions, and they are easy to blur, because the first one clears the login screen and quiets most manual testing. Authentication answers "are you Victhor?". Authorization answers "is this particular order Victhor's?". The shop handled the first with care: token, expiry, all in place. The second, they skipped.&lt;/p&gt;

&lt;p&gt;On a senior squad that second question belongs in the endpoint's definition of done, not in an audit six months later. That is the gap between treating authorization as a layer of the architecture and treating it as a detail you can leave for the end. And the end, spoiler, never arrives.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this hole shows up in almost every app we inherit
&lt;/h2&gt;

&lt;p&gt;The cause is almost always one innocent line of code. The dev takes the id from the URL and asks the database: "give me order 10431". The query works, the screen renders, the demo passes. What was missing is a second filter: "give me order 10431 that belongs to this user". One clause. That is the size of the hole.&lt;/p&gt;

&lt;p&gt;But it shows up nowhere when you glance at the product from above. The customer sees his own order, all fine. Nobody, in a normal flow, types someone else's id. The problem only exists for whoever goes looking, and a vendor paid by feature delivered has no reason to go looking. Isolating one customer from another never becomes a pretty screenshot at the end of the sprint.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9r33kq6db5z3jps13k2w.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9r33kq6db5z3jps13k2w.jpg" alt="Keeping one customer separate from another is an architecture decision, drawn on paper before the first endpoint, not a patch bolted on after the data already leaked." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Keeping one customer separate from another is an architecture decision, drawn on paper before the first endpoint, not a patch bolted on after the data already leaked.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  23 of 61 endpoints, and nobody had noticed
&lt;/h2&gt;

&lt;p&gt;In 2024 we took over a logistics platform with exactly this pattern. It had been running for about two years. When we mapped the endpoints, 23 of the 61 that returned customer data checked for no owner at all: being logged in was enough. Any account could list any other account's deliveries by swapping an id. As far as we could reconstruct from the logs, nobody had exploited it. But "nobody has exploited it yet" and "it is safe" are very different sentences.&lt;/p&gt;

&lt;p&gt;That 23-of-61 may not be representative, and I'll own the bias: we get called precisely for the projects that already smell like trouble, so my sample leans toward chaos. Even so, on the apps that arrive relatively healthy, the authorization inventory rarely comes back with zero gaps on the first pass. The pattern repeats too often to be bad luck.&lt;/p&gt;

&lt;h2&gt;
  
  
  How we close it by default 🔒
&lt;/h2&gt;

&lt;p&gt;The rule Revin applies with every client is boring precisely because it is simple: no endpoint that takes an id ships to production without a test that tries to read another owner's data and demands a 403 in return. Not the happy-path test, the malicious-path one. And it lives in CI, runs on every pull request, and depends on nobody remembering.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Owner filter on the query&lt;/strong&gt;: every read query carries the user, never just the resource id.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A negative test in CI&lt;/strong&gt;: for each endpoint, a case that attempts the wrong access and expects a 403 or 404.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;One central policy layer&lt;/strong&gt;: the "can they see this?" decision lives in a single place, not scattered across controllers where someone forgets.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you can't say how many of your endpoints check ownership today, that is exactly the kind of thing we open up in a 30-minute diagnostic: &lt;a href="https://revin.com.br/en/diagnostic-sprint" rel="noopener noreferrer"&gt;https://revin.com.br/en/diagnostic-sprint&lt;/a&gt;. It takes less time than the meeting where the problem turns into a crisis.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I told that client
&lt;/h2&gt;

&lt;p&gt;I didn't tell him to sue the previous shop or to rebuild from scratch. Rebuilding from scratch is almost never the answer, and that is a story for another day. I told him to stop signing the next phase with a crew that treated access separation as optional, and to require, from whoever stayed on, an authorization test per endpoint in the pipeline. It is verifiable. Either it is running there, or it isn't.&lt;/p&gt;

&lt;p&gt;The good part of this problem is that closing it costs little once someone decides to look. Finding out late, with a customer asking why they saw the neighbor's data, costs the rest. If you want an honest read on how your access is separated today, the conversation starts here: &lt;a href="https://revin.com.br/en/schedule-a-call" rel="noopener noreferrer"&gt;https://revin.com.br/en/schedule-a-call&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;We write about software engineering, managed squads and technical decisions every week at &lt;a href="https://revin.com.br/en/blog?utm_source=devto&amp;amp;utm_medium=syndication" rel="noopener noreferrer"&gt;revin.com.br&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>product</category>
      <category>founders</category>
    </item>
    <item>
      <title>High test coverage is where production bugs hide best</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Fri, 07 Aug 2026 16:44:43 +0000</pubDate>
      <link>https://dev.to/revinsoftware/high-test-coverage-is-where-production-bugs-hide-best-5b7m</link>
      <guid>https://dev.to/revinsoftware/high-test-coverage-is-where-production-bugs-hide-best-5b7m</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://revin.com.br/en/blog/high-test-coverage-hides-production-bugs" rel="noopener noreferrer"&gt;Revin blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A founder in fintech texted me a screenshot at 11pm on a Thursday. It was his vendor's test report: 92% coverage, all green, the kind of badge you are proud to pin to the README. In the same screenshot, open in another tab, his product's checkout screen frozen. He is not a Revin client. He had come to me through a referral, wanting a second opinion before he renewed the contract with the shop that built the thing.&lt;/p&gt;

&lt;p&gt;His question was honest and uncomfortable: how does 92% coverage let the checkout go down? The answer fits in one line, and it is behind half the surprises I see in production. Test coverage does not measure whether your software works. It measures how many lines ran while the tests executed. Those are different things, and the gap between them is where the bug hides.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi7rtlz7q7u2za51xvhyk.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fi7rtlz7q7u2za51xvhyk.jpg" alt="A test can walk through this entire component, log the value to the console, and never once check whether it is right." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A test can walk through this entire component, log the value to the console, and never once check whether it is right.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What test coverage actually counts
&lt;/h2&gt;

&lt;p&gt;Coverage is an execution metric. The tool instruments your code, runs your suite, and flags every line, branch, and function touched at least once. Then it divides what was touched by the total and hands you a percentage. It is genuinely good for one thing: finding code no test comes near. A block sitting at 0% is a real warning.&lt;/p&gt;

&lt;p&gt;The problem is what it ignores. The metric records that the line ran. Whether anyone checked the result of that line, it has no idea. A test that calls calculateInterest(balance) and never inspects the return marks the function covered all the same. The function ran. The value it produced went unreviewed. On the dashboard, that green looks identical to a test that checks the number to the cent.&lt;/p&gt;

&lt;p&gt;That is roughly what we found in the fintech suite. Plenty of tests exercised the code and stopped there, never asking whether the result matched what it should be.&lt;/p&gt;

&lt;h2&gt;
  
  
  How does a test run, pass, and verify nothing?
&lt;/h2&gt;

&lt;p&gt;There are three common ways for a test to go hollow. None of them shows up in the coverage percentage:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Assertion-free tests:&lt;/strong&gt; they call the function and use no expect at all. If nothing throws, they pass. The line is covered; the behavior is not.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Over-mocking:&lt;/strong&gt; the test stubs so many dependencies that it ends up checking its own mock. You program the answer and, three lines later, assert that the answer is the one you programmed.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Snapshots on autopilot:&lt;/strong&gt; the test compares output against a saved snapshot. When it breaks, plenty of people just run the update, the snapshot becomes the new truth, and the test goes green again with nobody reading the diff.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;All three inflate coverage and hold nothing up. Because the number climbs, everyone feels safer. The vendor included, now quoting that 92% as if a percentage were the same thing as quality.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwy4anqwlynb2erm6k71.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fdwy4anqwlynb2erm6k71.jpg" alt="Picking up a test and asking 'what happens if I break this line on purpose?' tells you more than any percentage in the report." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Picking up a test and asking 'what happens if I break this line on purpose?' tells you more than any percentage in the report.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The number the body shop loves to put on a slide
&lt;/h2&gt;

&lt;p&gt;Coverage turned into a sales metric. It climbs easily, because writing an assertion-free test is fast. It fits in a round number. And it impresses anyone who does not read code. For a shop that bills by delivery, it is the perfect indicator: it grows almost on its own, and the client has no way to challenge it from the outside.&lt;/p&gt;

&lt;p&gt;The incentive is crooked. When the contract pays for a feature shipped with tests, the vendor optimizes to hit the percentage. Preventing the production incident is separate work, duller and pricier, that nobody is paying for out loud. It is not that they are lying. They are measuring the easy thing and charging for it.&lt;/p&gt;

&lt;p&gt;On a senior squad, a test is &lt;strong&gt;reviewed like production code&lt;/strong&gt;. A pull request that adds an assertion-free test does not pass review: it comes back with a single question, what is this guaranteeing? That is how we look after client suites at Revin, and it is what separates coverage that protects you from coverage that just decorates you. If nobody has ever reviewed your tests with that kind of rigor, start with &lt;a href="https://revin.com.br/en/diagnostic-sprint" rel="noopener noreferrer"&gt;an honest audit of what is there today&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🧬 Mutation testing: who tests your tests?
&lt;/h2&gt;

&lt;p&gt;There is an old, underused technique aimed squarely at this blind spot: mutation testing. The idea is almost cruel. A tool takes your code and plants bugs on purpose: it flips a &amp;gt; into &amp;gt;=, negates a condition, deletes a line, or turns a + into a -. Each broken version is a mutant, and your whole suite runs against every one of them.&lt;/p&gt;

&lt;p&gt;The read is blunt. If the code was broken and no test complained, that test was protecting nothing: the mutant survived. What matters stops being how many lines you cover and becomes how many of those planted bugs your suite actually kills. Tools like Stryker in the JavaScript and TypeScript world, and PIT in Java, do exactly this and give you back a mutation score.&lt;/p&gt;

&lt;p&gt;We ran Stryker on that 92% suite. The mutation score came back at 41%. Put plainly: more than half the bugs we planted on purpose sailed straight past the green tests. That number moves with the tool and with what you ask it to mutate, I will grant that. I do not treat 41% as a law of physics. But even shaving off a generous margin, the conclusion holds: the comfortable suite covered plenty and, when it came to catching a real bug, let it through. That is the kind of read we do in the first days with a client, and a few of them become &lt;a href="https://revin.com.br/en/cases" rel="noopener noreferrer"&gt;a published case later&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The green that counts is the one you can break
&lt;/h2&gt;

&lt;p&gt;If you are a founder, do not ask for the coverage percentage. It will come back high and tell you nothing. Ask your team or your vendor something else: what happens to the suite if someone breaks a business rule on purpose? If the answer is silence, or a 'we trust the ninety-something percent,' you already have your diagnosis.&lt;/p&gt;

&lt;p&gt;I will be fair. Not every project needs this. A five-page marketing site is never going to set up mutation testing, and that is perfectly fine: high coverage already does the job there, and hiring a senior squad would be a waste of your money. But anything that touches money, sensitive data, or a flow that takes the whole operation down when it fails, the pretty number on its own is theater.&lt;/p&gt;

&lt;p&gt;The fintech founder did not switch vendors out of anger. He switched because, for the first time, someone sat down with him and showed him in plain terms how much that comfortable suite was really defending his product. It was very little. If you suspect your own coverage is closer to decoration, the first move is to look hard at what your suite actually guarantees today, and that audit fits inside &lt;a href="https://revin.com.br/en/schedule-a-call" rel="noopener noreferrer"&gt;one conversation&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;We write about software engineering, managed squads and technical decisions every week at &lt;a href="https://revin.com.br/en/blog?utm_source=devto&amp;amp;utm_medium=syndication" rel="noopener noreferrer"&gt;revin.com.br&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>product</category>
      <category>founders</category>
    </item>
    <item>
      <title>Security isn't a feature you bolt on. It's how the team works.</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Thu, 06 Aug 2026 15:10:19 +0000</pubDate>
      <link>https://dev.to/revinsoftware/security-isnt-a-feature-you-bolt-on-its-how-the-team-works-5h9a</link>
      <guid>https://dev.to/revinsoftware/security-isnt-a-feature-you-bolt-on-its-how-the-team-works-5h9a</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://revin.com.br/en/blog/security-is-not-a-feature-its-how-the-team-works" rel="noopener noreferrer"&gt;Revin blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I read software development proposals almost every week. Part of my job at Revin is to look at what a founder is about to sign before they sign it. And there is one line that shows up so often it has become a running joke on our side. It turns up in fintech, in healthtech, in marketplaces, regardless of the sector. Always somewhere in the middle of the document, in the calm tone of someone simply sorting priorities: "we'll handle security in a later phase".&lt;/p&gt;

&lt;p&gt;A later phase sounds perfectly reasonable when you are racing to launch and the budget is tight. The trouble is that this "later" almost never becomes a real date on the calendar. It becomes an item that slides down the backlog every sprint, pushed aside by something that feels more urgent. And almost everything feels more urgent than security, right up until the day it doesn't.&lt;/p&gt;

&lt;p&gt;This piece is for whoever is about to hire the people who build their software, and wants to know in advance whether security is &lt;strong&gt;something that team does or something it merely promises to do&lt;/strong&gt;. Those are very different things, and the difference is visible before you sign.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6ua6vc2f7p4o1k4jhzj.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh6ua6vc2f7p4o1k4jhzj.jpg" alt="Where security shows up, or doesn't: less in a contract clause, more in the lines the team writes every single day." width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Where security shows up, or doesn't: less in a contract clause, more in the lines the team writes every single day.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The "later" that never comes
&lt;/h2&gt;

&lt;p&gt;Why is security always the easiest thing to postpone? Because it is invisible while it works. Nobody opens the app in the morning and thinks "great, my data didn't leak today". A new feature gets a demo, a screenshot in Slack, a round of applause from the board. Security done well shows up nowhere, until the day its absence becomes the only thing anyone can see.&lt;/p&gt;

&lt;p&gt;Then there is the vendor incentive nobody mentions in the room. Whoever gets paid for visible output optimizes for visible output. A body shop billing by the screen has no reason to spend three days reviewing an access rule the client does not even know exists. It is not bad faith. It is the contract spelling out what matters, and security was not written into it.&lt;/p&gt;

&lt;p&gt;I have seen exceptions, of course. The careful freelancer who does the right thing without being asked does exist. You just have no way of knowing which one you have got before you hire, and betting your customers' database on "maybe this one is a good one" tends to get expensive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the gap shows, and it's never a movie hack
&lt;/h2&gt;

&lt;p&gt;When a system leaks, the real story is almost never the one from the films. There is rarely a hooded hacker breaking encryption at three in the morning. What is there is a lot more boring than that.&lt;/p&gt;

&lt;p&gt;Earlier this year Revin was brought in to review the code of a marketplace with around 20,000 active users. Lean team, built entirely by two freelancers in a hurry. Within half a day of reading we found the usual suspect: the payment API key written straight into the code, in plain text, in a commit from 2023. It was sitting in the Git history, visible to anyone who cloned the repo. Nobody had rotated it because nobody remembered it was there.&lt;/p&gt;

&lt;p&gt;That kind of hole tends to live in four places:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A secret in the code&lt;/strong&gt;: API keys, database passwords and tokens committed in plain text.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Access nobody revokes&lt;/strong&gt;: the freelancer left eight months ago and his production credential is still alive.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Permissions that are far too broad&lt;/strong&gt;: everyone became an admin because setting up proper roles was a hassle at the time.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;A dependency frozen in time&lt;/strong&gt;: a library with a known flaw for two years that nobody updated because it was working.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of these needs an evil genius to exploit. It needs a curious intern, a resentful former contractor, or just a bad day. And they all share the same root: they appeared because nobody on the team was in the habit of looking.&lt;/p&gt;

&lt;p&gt;Finding this early is half the battle. The &lt;a href="https://revin.com.br/en/diagnostic-sprint" rel="noopener noreferrer"&gt;Diagnostic Sprint&lt;/a&gt; grew out of roughly that: two weeks, code on the table, and an honest picture of what is exposed handed back to you while fixing it is still cheap.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yl27w1g39wliuv7rbi2.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6yl27w1g39wliuv7rbi2.jpg" alt="Real security doesn't live in an annual report. It lives in the code review that happens before the merge." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Real security doesn't live in an annual report. It lives in the code review that happens before the merge.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Security is code review, not an audit
&lt;/h2&gt;

&lt;p&gt;The difference between a team that has security and one that only promises it is not an expensive tool or a certificate framed on the wall. It is a habit, and that habit is visible in the working rhythm of whoever you hired.&lt;/p&gt;

&lt;p&gt;On a senior squad, security is not a stage that happens at the end of the project. It is a lens that stays on the whole time. When someone opens a pull request, the reviewer does not only check whether the code works. They check whether the new endpoint verifies permissions, whether some sensitive field is being written to a log by accident, whether that query accepts a parameter it should not. Least privilege stops being a nice phrase in a policy doc and becomes the default way to create access: it starts closed and opens only as much as needed.&lt;/p&gt;

&lt;p&gt;That is how it works on Revin's squads, and not out of any special virtue on our part. It is simply cheaper. Reviewing a permission inside the pull request costs ten minutes of whoever is on review duty. Discovering the wrong permission after it has already leaked costs you the client.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not the same as chasing SOC 2
&lt;/h2&gt;

&lt;p&gt;A caveat here, because security as a habit keeps getting confused with security as paperwork. They are not the same thing. You don't need SOC 2 in year one, nor a pricey quarterly pentest, and you certainly don't need a CISO before you have product-market fit. Chasing compliance too early is usually money down the drain, something we argued in &lt;a href="https://revin.com.br/en/blog/premature-compliance-why-year-1-startups-should-not-pursue-soc-2" rel="noopener noreferrer"&gt;why a year-one startup shouldn't chase SOC 2&lt;/a&gt;.&lt;/p&gt;

&lt;p&gt;A certificate is something you buy off the shelf. The habit comes from the team itself: either they already work this way, or they will learn it on your project, on your timeline, with your data. When it is already baked into how people write code, it costs very little.&lt;/p&gt;

&lt;p&gt;So next time a proposal lands in your lap and your eyes catch that line, "we'll handle security later", be suspicious of the verb. The question that matters is not when it will get done, it is whether that team already does it by default. A team that treats security as a habit does not even write the sentence, because for them there is no later phase: it has been part of the work since the first commit. Whoever promises it for later is telling you, in plain words, that the habit is not there. You can hire them anyway, but at least you heard the warning.&lt;/p&gt;

&lt;p&gt;In the end, security is less about what you buy and more about who you let into your code. If you want to see how an embedded squad handles this day to day, or to read about &lt;a href="https://revin.com.br/en/blog/security-in-ai-environments-prompt-injection-data-leakage-supply-chain" rel="noopener noreferrer"&gt;the other vectors that show up once AI enters the workflow&lt;/a&gt;, just &lt;a href="https://revin.com.br/en/schedule-a-call" rel="noopener noreferrer"&gt;book a call with us&lt;/a&gt;.&lt;/p&gt;




&lt;p&gt;We write about software engineering, managed squads and technical decisions every week at &lt;a href="https://revin.com.br/en/blog?utm_source=devto&amp;amp;utm_medium=syndication" rel="noopener noreferrer"&gt;revin.com.br&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>founders</category>
      <category>product</category>
      <category>softwaredevelopment</category>
    </item>
    <item>
      <title>It was fast in the demo. Then the data grew.</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Wed, 05 Aug 2026 14:50:55 +0000</pubDate>
      <link>https://dev.to/revinsoftware/it-was-fast-in-the-demo-then-the-data-grew-5cnp</link>
      <guid>https://dev.to/revinsoftware/it-was-fast-in-the-demo-then-the-data-grew-5cnp</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://revin.com.br/en/blog/fast-in-the-demo-then-the-data-grew" rel="noopener noreferrer"&gt;Revin blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a sentence I hear almost every week, always in the same surprised tone: "at launch the app flew, now it hangs." The last time, it came from a niche e-commerce shop, a team of about 40, that came to Revin for a second opinion on a product another vendor had built. The orders screen, which used to open in a blink when the site went live, was taking 14 seconds to load. Nobody had touched the code in months. The only thing that changed was the size of the database.&lt;/p&gt;

&lt;p&gt;The easy read is to blame the infrastructure. "We need a bigger server," "Postgres can't keep up." It almost never is. Most times Revin pops the hood on an app like this, &lt;strong&gt;the server is bored, sitting at 12% CPU&lt;/strong&gt;, waiting on code that talks to the database the wrong way.&lt;/p&gt;

&lt;p&gt;And the nasty part is that this defect is invisible exactly when you would most want to catch it: on delivery day, with test data, everything runs beautifully.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flx6w4f9xgygbd2kktwby.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flx6w4f9xgygbd2kktwby.jpg" alt="A chart on a laptop screen: queries that were flat at launch turn into spikes as the data grows." width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A chart on a laptop screen: queries that were flat at launch turn into spikes as the data grows.&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the demo never shows this
&lt;/h2&gt;

&lt;p&gt;In the demo, the database has 200 rows. Maybe 500. With 500 rows, everything is fast. You can write the laziest query in the world, without a single index, and the database scans the whole table in under a millisecond and nobody notices.&lt;/p&gt;

&lt;p&gt;The classic case is what the Revin engineers call the N+1 problem. A screen lists 50 orders and, for each order, the code fires a fresh query to fetch the customer. Then another for the line items. Your 50 orders become 101 round trips to the database, sometimes 150. With the test seed it happens too fast to notice. In production, with a full table and the network in the middle, every round trip costs you. Multiply.&lt;/p&gt;

&lt;p&gt;The ORM helps hide the crime. An innocent line that reads like "grab this order's customer" is, in reality, a database query fired inside a loop. The dev who wrote it never sees the query. The founder who approved the demo, even less so.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bill that grows with your success
&lt;/h2&gt;

&lt;p&gt;Here is the cruel part. While the product has not taken off, nobody feels a thing. The database is small, the bad queries slip by unnoticed, and the vendor hands everything over "on time, working." The bill for the bad design only arrives once the product starts to succeed.&lt;/p&gt;

&lt;p&gt;At that e-commerce shop I mentioned, the orders screen was firing more than 1,200 queries to render a single page. With 300 orders early on, nobody complained. Once the catalog grew and they crossed 38,000 orders, that same screen turned into the 14-second nightmare and support started hearing "the site is down" all day long. An honest caveat about that number: the people who call me are already having their worst day, so &lt;strong&gt;my sample is biased toward disaster&lt;/strong&gt;. Even so, the pattern repeats so often it reads like a script.&lt;/p&gt;

&lt;p&gt;In other words: the real test of your software is not the launch. It is month six, when it succeeded enough to get slow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipp84r38pfeassh5o5mf.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipp84r38pfeassh5o5mf.jpg" alt="A data-center aisle: most of the time the problem is not here, it is in how the code asks for the data." width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A data-center aisle: most of the time the problem is not here, it is in how the code asks for the data.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If your busiest screen started dragging as the data grew, it is worth a technical diagnosis before you buy a bigger server. A &lt;a href="https://revin.com.br/en/diagnostic-sprint" rel="noopener noreferrer"&gt;Revin Diagnostic Sprint&lt;/a&gt; is a good place to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a senior squad checks before you even complain
&lt;/h2&gt;

&lt;p&gt;I asked the engineers at Revin what they look for in these cases, and the answer was almost disappointing in how basic it is. No magic. Just the discipline most teams skip because it takes effort and never shows up in a demo.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Run EXPLAIN ANALYZE&lt;/strong&gt; on the queries behind your busiest screens. It is the command that shows, with no guesswork, whether the database is scanning the whole table or using an index.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Turn on query logging&lt;/strong&gt; in development to hunt for N+1. If a screen fires 80 queries to render one list, something is wrong right there.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Add an index where the lookup hurts&lt;/strong&gt;, and only where it hurts. Too many indexes also cost you on writes and storage.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Test with production-scale volume&lt;/strong&gt;, not the 200-row seed. Fill the database with a million fake records and see what breaks before your customer does.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Set a budget&lt;/strong&gt;: no critical screen goes over X queries or Y milliseconds. And measure it every release, not once.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is rocket science. But notice that all of it costs effort today to head off a problem that only shows up six months from now, long after the cheap vendor cashed the check and vanished. That is why, at Revin, &lt;strong&gt;database performance goes into the definition of done from the first sprint&lt;/strong&gt;. A senior embedded squad pays that bill while it is still cheap.&lt;/p&gt;

&lt;p&gt;Want to see how this looks in a real project? Some of &lt;a href="https://revin.com.br/en/cases" rel="noopener noreferrer"&gt;our cases&lt;/a&gt; show the before and after of exactly this kind of bottleneck.&lt;/p&gt;

&lt;h2&gt;
  
  
  The index that was worth 9 seconds
&lt;/h2&gt;

&lt;p&gt;Back to the e-commerce shop. The most impactful fix Revin made in the first week was almost embarrassingly simple: a missing index on a date column. A query that took 9 seconds started answering in 40 milliseconds. There was no rewrite and no new server. There was someone reading the EXPLAIN nobody had bothered to read.&lt;/p&gt;

&lt;p&gt;To be fair to the other side: if you have a three-screen MVP still hunting for its first ten customers, tuning indexes now is a waste. At that stage your challenge is finding someone who will pay; scaling a database is a problem for much later. Hire someone fast, ship it ugly, keep moving. The index becomes urgent the day the product starts working, and that is exactly the day the cheap vendor never planned for.&lt;/p&gt;

&lt;p&gt;If your system is already past that stage and has started to drag, do not swap servers in the dark. &lt;a href="https://revin.com.br/en/schedule-a-call" rel="noopener noreferrer"&gt;Book a call with Revin&lt;/a&gt; and we will look at what the database is trying to tell you.&lt;/p&gt;




&lt;p&gt;We write about software engineering, managed squads and technical decisions every week at &lt;a href="https://revin.com.br/en/blog?utm_source=devto&amp;amp;utm_medium=syndication" rel="noopener noreferrer"&gt;revin.com.br&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>softwaredevelopment</category>
      <category>product</category>
      <category>startup</category>
    </item>
    <item>
      <title>Why EU startups are trading Poland/Romania for Brazil in 2026</title>
      <dc:creator>Revin</dc:creator>
      <pubDate>Tue, 04 Aug 2026 12:37:51 +0000</pubDate>
      <link>https://dev.to/revinsoftware/why-eu-startups-are-trading-polandromania-for-brazil-in-2026-53p9</link>
      <guid>https://dev.to/revinsoftware/why-eu-startups-are-trading-polandromania-for-brazil-in-2026-53p9</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published on the &lt;a href="https://revin.com.br/en/blog/why-eu-startups-are-trading-poland-romania-for-brazil-in-2026" rel="noopener noreferrer"&gt;revin.com.br blog&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For a decade, the default nearshore for European startups was Poland (scale), Romania (cost), or Ukraine (deep cost). In 2026, that equation changed — and Brazil entered the radar as a serious alternative. Not by fashion; by numbers.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Revin has operated Brazilian squads with EU clients since 2024&lt;/strong&gt; and observed 5 factors that reorganized the European nearshore choice. &lt;strong&gt;For EU startups in 2026, Brazil wins on 3 of 5&lt;/strong&gt;; ties on 1; loses on 1 (timezone with late-day clients). For most scenarios, the total math closed in Brazil's favor.&lt;/p&gt;

&lt;p&gt;For &lt;strong&gt;founders and CTOs in Lisbon, Berlin, Amsterdam, Paris, Stockholm, Madrid&lt;/strong&gt; evaluating nearshore in 2026 — and who want data, not pitch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76sxgbnw47jcnzenzd6d.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F76sxgbnw47jcnzenzd6d.jpg" alt="Cost, timezone, seniority, culture, and compliance aligned on one side in 2026" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;Cost, timezone, seniority, culture, and compliance aligned on one side in 2026&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  📊 The 5 factors that changed
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Factor 1 — Cost: Poland got expensive, Brazil still competes
&lt;/h3&gt;

&lt;p&gt;Senior dev Poland: USD 75-100/h in 2026. Senior dev Brazil: USD 50-75/h. For a 5-person squad on a 12-month project, direct difference of USD 150k-300k. The additional coordination cost of Brazil (1-2h extra timezone for Western Europe clients) doesn't consume that delta.&lt;/p&gt;

&lt;h3&gt;
  
  
  Factor 2 — Seniority: Romania thinned; Brazil has active pool
&lt;/h3&gt;

&lt;p&gt;Romania lost much of the senior pool to German, Swiss, and North American companies on premium remote contracts. Brazil had the inverse trajectory: the fintech/SaaS ecosystem of the last 5 years built a large senior layer, still available for nearshore.&lt;/p&gt;

&lt;h3&gt;
  
  
  Factor 3 — Timezone: Brazil has 4-6h synchronous with Western Europe
&lt;/h3&gt;

&lt;p&gt;Lisbon, Madrid, Paris have 5-6h overlap with Brazilian business hours. Berlin, Amsterdam have 4-5h. Enough for technical discussion in calls, daily, sprint review synchronously. 24-48h email tag only happens with Stockholm/Helsinki clients and only at the day's edges.&lt;/p&gt;

&lt;h3&gt;
  
  
  Factor 4 — Geopolitical stability: Brazil became low-risk
&lt;/h3&gt;

&lt;p&gt;Russia-Ukraine conflict since 2022 reorganized regional risk. Enterprise vendor audits in 2026 avoid Eastern Europe by geographic and regulatory proximity. Brazil is a low-risk jurisdiction with an IP treaty and contractual stability.&lt;/p&gt;

&lt;h3&gt;
  
  
  Factor 5 — LGPD aligned with GDPR
&lt;/h3&gt;

&lt;p&gt;Poland/Romania operate GDPR as EU — no friction. Brazil operates LGPD, mirroring GDPR by 85% (including concepts like right of erasure, legal basis, DPO). For EU clients, a Brazil contract has practical reciprocity that makes international compliance simple.&lt;/p&gt;

&lt;h2&gt;
  
  
  🌍 Where Eastern Europe still wins (and it's honest to acknowledge)
&lt;/h2&gt;

&lt;p&gt;For clients in Stockholm, Helsinki, Tallinn, Riga: Eastern European timezone (CET) has near-full overlap; Brazil has only 3-4h. For those cases, direct comparison is fair — and sometimes the East wins.&lt;/p&gt;

&lt;p&gt;For very large volume (50+ devs allocated to a single client): Poland has scale Brazil is still building. For 3-15 person squads, scale isn't a differentiator.&lt;/p&gt;

&lt;p&gt;For specific stacks concentrated in European hubs (.NET enterprise, banking legacy Java): the East has depth. Brazil has depth in Ruby, Python, Node, Go, React Native, native mobile.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0pgbx2iwyea6aprejxq.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fk0pgbx2iwyea6aprejxq.jpg" alt="A Brazilian squad with European clients works on 5h synchronous overlap — not 48h email tag" width="800" height="533"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;A Brazilian squad with European clients works on 5h synchronous overlap — not 48h email tag&lt;/em&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  🚀 Who is making this switch in 2026
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;German and French fintech startups&lt;/strong&gt; needing senior Ruby/Node pool.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Dutch and Portuguese B2B SaaS&lt;/strong&gt; valuing product culture (not just technical engineering).&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;European healthtechs&lt;/strong&gt; needing LGPD/GDPR alignment and lean operation.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;British edtechs (post-Brexit)&lt;/strong&gt; wanting to reduce hourly cost without going to India.&lt;/p&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;📢 Evaluating switching EU vendor to Brazil in 2026? Revin operates senior Brazilian squads with clients in Lisbon, Madrid, Berlin, and Amsterdam. &lt;a href="https://revin.com.br/en/schedule-a-call" rel="noopener noreferrer"&gt;Book a Discovery Call&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  🎯 Conclusion: the equation changed in 2025, closed in 2026
&lt;/h2&gt;

&lt;p&gt;For European startups in 2020, choosing Poland was obvious. In 2023, still reasonable. In 2026, it demands re-running the math — and in 60% of typical scenarios, Brazil wins. Not by marketing; by numbers.&lt;/p&gt;

&lt;p&gt;📢 &lt;a href="https://revin.com.br/en/cases" rel="noopener noreferrer"&gt;See our international case studies&lt;/a&gt; for examples where Revin already delivered to EU clients.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Read more at &lt;a href="https://revin.com.br?utm_source=devto&amp;amp;utm_medium=syndication&amp;amp;utm_content=why-eu-startups-are-trading-poland-romania-for-brazil-in-2026" rel="noopener noreferrer"&gt;revin.com.br&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>softwareengineering</category>
      <category>founders</category>
      <category>startup</category>
      <category>softwaredevelopment</category>
    </item>
  </channel>
</rss>
