<?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: sunnydachs</title>
    <description>The latest articles on DEV Community by sunnydachs (@sunnydachs).</description>
    <link>https://dev.to/sunnydachs</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%2F4111184%2F2b05ec86-d8ff-4a24-93d4-81c7a1ca48c2.png</url>
      <title>DEV Community: sunnydachs</title>
      <link>https://dev.to/sunnydachs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sunnydachs"/>
    <language>en</language>
    <item>
      <title>I changed a tool's schema four ways and watched three agent frameworks break differently — 36 runs measured</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Fri, 25 Sep 2026 18:23:01 +0000</pubDate>
      <link>https://dev.to/sunnydachs/i-changed-a-tools-schema-four-ways-and-watched-three-agent-frameworks-break-differently-36-runs-ebk</link>
      <guid>https://dev.to/sunnydachs/i-changed-a-tools-schema-four-ways-and-watched-three-agent-frameworks-break-differently-36-runs-ebk</guid>
      <description>&lt;p&gt;If you run agents with tools, this day always comes:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;"I need to change this tool's arguments — will the agent survive?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Tool schema changes are a daily event in agent development. An argument gets renamed. A type changes. An argument is removed. A new required argument appears.&lt;/p&gt;

&lt;p&gt;The ideal is to fix the prompt and all call sites at the same time. In practice, "the schema changed first, and the prompts and callers haven't caught up" is a state that always happens.&lt;/p&gt;

&lt;p&gt;In my previous article, I ran a comparison across Strands, LangGraph, and CrewAI with the same task. An argument rename (text → content) passed 9/9 in all three frameworks.&lt;/p&gt;

&lt;p&gt;That raised a fair question:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;"Was that rename the first schema change you tried — or the one that survived after a harsher one broke everything?"&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;In other words: &lt;strong&gt;did rename only work because it happens to be the easiest change?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;To answer it, I split schema changes into four harshness levels and re-ran the measurement across 36 runs.&lt;/p&gt;

&lt;p&gt;Repo (all code, all traces, all analysis scripts):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment design
&lt;/h2&gt;

&lt;p&gt;Same task, same model across all runs, same recording proxy: 3 frameworks × 4 levels × 3 runs = 36 runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[The four schema-change levels]
rename: argument renamed        text -&amp;gt; content
type:   type changed            str -&amp;gt; int (a document id)
remove: argument deleted        the text arg is gone entirely
add:    new required argument   "note", never mentioned in the prompt
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;At each level, the only thing that changes is &lt;strong&gt;the word_count tool's signature&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;The task prompt stays fixed at every level: "write an 80–120 word digest and verify the word count with word_count" — not one character changes.&lt;/p&gt;

&lt;p&gt;What I measured:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;wrong-arg calls: calls whose arguments don't match the schema&lt;/li&gt;
&lt;li&gt;schema errors: how often the tool layer rejected a call&lt;/li&gt;
&lt;li&gt;recovery: did the model self-correct within the run, give up, or never notice the error at all&lt;/li&gt;
&lt;li&gt;silent failure: the process exits 0 but the word count was never actually verified&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The result: each level dies a different way
&lt;/h2&gt;

&lt;p&gt;3 runs per cell:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Level&lt;/th&gt;
&lt;th&gt;Strands&lt;/th&gt;
&lt;th&gt;LangGraph&lt;/th&gt;
&lt;th&gt;CrewAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;rename&lt;/td&gt;
&lt;td&gt;✅ 3/3 verified&lt;/td&gt;
&lt;td&gt;✅ 3/3 verified&lt;/td&gt;
&lt;td&gt;✅ 3/3 verified&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;type&lt;/td&gt;
&lt;td&gt;⚠️ 0/3 verified (silent)&lt;/td&gt;
&lt;td&gt;❌ 3/3 crash&lt;/td&gt;
&lt;td&gt;❌ 3/3 dead&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;remove&lt;/td&gt;
&lt;td&gt;⚠️ 0/3 verified (silent)&lt;/td&gt;
&lt;td&gt;❌ 3/3 crash&lt;/td&gt;
&lt;td&gt;⚠️ 0/3 verified (silent)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;add&lt;/td&gt;
&lt;td&gt;✅ 3/3 verified&lt;/td&gt;
&lt;td&gt;❌ 3/3 crash&lt;/td&gt;
&lt;td&gt;✅ 3/3 verified&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The direct answer to the question: &lt;strong&gt;rename was not "the first change I tried" — it was the one that survived.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;And it was the only level that survived. One step up the ladder, and all three frameworks die in three different ways.&lt;/p&gt;

&lt;h2&gt;
  
  
  Strands: degrades silently
&lt;/h2&gt;

&lt;p&gt;At the type level (the argument goes from str to int), Strands behaved like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model:  dutifully sends a numeric document id
tool:   returns "document not found"
model:  outputs the digest anyway and finishes
result: exit 0, zero verifications, no error surfaced
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No error ever surfaces. Only the token consumption swells to 2.4× (rename mean 2,505 → type mean 6,004).&lt;/p&gt;

&lt;p&gt;At the remove level, the argument-less tool returned a quiet placeholder (8 words), and the model accepted it as "verification passed" in 3/3 runs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The process exits 0 — and nothing was verified.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is a silent failure.&lt;/p&gt;

&lt;p&gt;I wrote in the previous article that agent failures happen quietly. Schema change is one of the triggers that can cause exactly that.&lt;/p&gt;

&lt;h2&gt;
  
  
  LangGraph: fails loudly
&lt;/h2&gt;

&lt;p&gt;LangGraph is the opposite.&lt;/p&gt;

&lt;p&gt;Because the tool is called directly inside Python code, a signature change becomes an immediate TypeError.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;verify node: word_count(state["draft"]) runs
tools_harsh: TypeError (the signature changed)
graph:      the draft is finished, but the result is never emitted — dead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;9/9 runs across type, remove, and add crashed inside the verify node. Each run gets as far as the draft-generation LLM call, then dies at the verification point.&lt;/p&gt;

&lt;p&gt;Silent failure effectively does not exist in LangGraph. &lt;strong&gt;In exchange, no run ever produces output.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The failure is loud — and it is a full outage. (Because the error is explicit and the process halts, you notice immediately — but as a service, it is completely down.)&lt;/p&gt;

&lt;h2&gt;
  
  
  CrewAI: does both
&lt;/h2&gt;

&lt;p&gt;CrewAI sits in between.&lt;/p&gt;

&lt;p&gt;At the type level, the model stuffed the digest text into the int field: 9 wrong-arg calls across 3 runs. The tool layer returned a JSON parse error, and the next request carrying that error was rejected by the provider with a 400.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model:          stuffs the text into an int field
tool:           JSON parse error
next request:   provider 400 -&amp;gt; dead
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;CrewAI's failure shape is &lt;strong&gt;not the schema mismatch itself — it dies on the way back from it.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;At the remove level, CrewAI believed the quiet placeholder just like Strands and exited 0. (The word-count check the agent was supposed to perform is effectively skipped, yet the run still reports completion.)&lt;/p&gt;

&lt;h2&gt;
  
  
  "add" was the only harsh change that model-driven frameworks survived
&lt;/h2&gt;

&lt;p&gt;One interesting reversal.&lt;/p&gt;

&lt;p&gt;The add level — a new required argument the prompt never mentions — looks like the most unreasonable change of the four.&lt;/p&gt;

&lt;p&gt;But Strands and CrewAI fabricated &lt;strong&gt;a plausible value&lt;/strong&gt; in 6/6 runs and resumed verification:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;model's fabrication: "Draft digest of AI agents news"
-&amp;gt; a natural description, inferred from the schema itself
-&amp;gt; verification resumes, 3/3 pass
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The model can read the schema and reason about it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changes the model can infer from the schema itself get absorbed.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Changes that contradict the prompt (type) or delete the verification path itself (remove) do not.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;There is a practical lesson here: when you add a new required argument, if its name and description are designed so a model can fill them in by inference, the agent is surprisingly likely to survive.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure shapes, summarized
&lt;/h2&gt;

&lt;p&gt;Across 36 runs, each framework's way of breaking is now quantified:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Failure shape&lt;/th&gt;
&lt;th&gt;How you detect it&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strands&lt;/td&gt;
&lt;td&gt;silent degradation (exit 0, no verification)&lt;/td&gt;
&lt;td&gt;traces only&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;full outage (immediate TypeError death)&lt;/td&gt;
&lt;td&gt;process monitoring, instantly&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrewAI&lt;/td&gt;
&lt;td&gt;dies during recovery (provider 400)&lt;/td&gt;
&lt;td&gt;both process monitoring and traces&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which framework is "safe"? The data's answer: &lt;strong&gt;none of them, alone.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The one that fails quietly needs auditing. The one that fails loudly trades that for availability. To get a schema change through safely, you have to layer defenses.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;3 runs per cell show direction, not statistical claims&lt;/li&gt;
&lt;li&gt;The tool is a local function — real HTTP API schema changes also involve network, latency, and status codes&lt;/li&gt;
&lt;li&gt;The quiet placeholder at the remove level is a design choice of the experiment; a real deployment would normally return an error&lt;/li&gt;
&lt;li&gt;Single model measured; schema-inference ability varies by model&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;All code, all traces, and all analysis scripts are public. The README has the commands for every experiment so far (36 + the earlier 108 runs).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This is a personal OSS project — no warranty, use at your own risk. Issues and improvement ideas are welcome.&lt;/p&gt;

&lt;p&gt;Previously in this series: &lt;a href="https://dev.to/sunnydachs/what-happens-when-enterprise-requirements-hit-strands-langgraph-and-crewai-45-runs-measured-ocg"&gt;what happens when enterprise requirements hit the same three frameworks (45 runs)&lt;/a&gt; and &lt;a href="https://dev.to/sunnydachs/do-agents-survive-a-crash-and-why-does-an-llm-retry-execute-the-same-side-effect-twice-34-runs-52gd"&gt;do agents survive a crash (34 runs)&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>llm</category>
      <category>langgraph</category>
      <category>python</category>
    </item>
    <item>
      <title>AI answers are easy to get but expensive to read — a CLI that filters them down</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Fri, 25 Sep 2026 01:02:52 +0000</pubDate>
      <link>https://dev.to/sunnydachs/ai-answers-are-easy-to-get-but-expensive-to-read-a-cli-that-filters-them-down-224k</link>
      <guid>https://dev.to/sunnydachs/ai-answers-are-easy-to-get-but-expensive-to-read-a-cli-that-filters-them-down-224k</guid>
      <description>&lt;p&gt;You ask an AI assistant a question and an answer comes back. Reading it is the expensive part. I find the long round-trips tiring.&lt;/p&gt;

&lt;p&gt;Ask it how to remove duplicates from a CSV in Python and you get 800 characters covering BOMs, encodings, and pandas design philosophy. What you actually need is twelve lines of code and one way to check it works.&lt;/p&gt;

&lt;p&gt;Telling the assistant "be concise" ahead of time doesn't always help. Asking "tldr pls" costs another round-trip.&lt;/p&gt;

&lt;p&gt;So I built a CLI that filters the answer &lt;strong&gt;after&lt;/strong&gt; it's produced. Feed it a Markdown answer and it emits three sections: conclusion, minimal code, and verification. It takes stdin, so you just pipe it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/concise-md" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/concise-md&lt;/a&gt;&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;concise answer.md
&lt;span class="c"&gt;## Conclusion&lt;/span&gt;
- The standard approach is exponential backoff with jitter:
&lt;span class="c"&gt;## Minimal code&lt;/span&gt;
&amp;lt;emits the code block, verbatim&amp;gt;
&lt;span class="c"&gt;## How to verify&lt;/span&gt;
- Test it with a mock that returns 429 three &lt;span class="nb"&gt;times &lt;/span&gt;and &lt;span class="k"&gt;then &lt;/span&gt;200.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The code section carries the actual code block from the answer, unchanged:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;RETRYABLE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;503&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;504&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;get_with_retry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
        &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;kwargs&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestException&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;continue&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;status_code&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;RETRYABLE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;
    &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;RuntimeError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;gave up&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero dependencies, Python 3.11+, no LLM calls. Runs in 0.02 seconds.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not summarize with an LLM
&lt;/h2&gt;

&lt;p&gt;I built that version first and tried it.&lt;/p&gt;

&lt;p&gt;On a long Japanese input, the summary itself came out clean, but &lt;strong&gt;the code was broken&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;In the deduplication example, the model generated a line that re-ran the same duplicate-count calculation on an already-deduplicated DataFrame, as part of "reorganizing" the code. Run it and you get &lt;code&gt;removed 0 rows&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;For a learning artifact, that is fatal.&lt;/p&gt;

&lt;p&gt;Summarizing prose is what LLMs are good at. Code is a different story, so I drew the line there: don't touch code. With rule-based parsing, code blocks come out byte-for-byte identical. If the goal is keeping an AI answer as study material, that guarantee is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rules behind it
&lt;/h2&gt;

&lt;p&gt;The implementation is plain Markdown parsing.&lt;/p&gt;

&lt;p&gt;It splits the input into heading-delimited sections, detaches code fences from prose, joins wrapped paragraphs, and breaks prose into sentences.&lt;/p&gt;

&lt;p&gt;The conclusion is the first sentence containing a marker such as &lt;code&gt;tl;dr&lt;/code&gt;, "in short", or "the standard approach", falling back to the opening sentence of the first section.&lt;/p&gt;

&lt;p&gt;Code is capped at three blocks of 25 lines each. A block that opens with "DON'T" is treated as an anti-pattern and placed after the preferred example, not before.&lt;/p&gt;

&lt;p&gt;For verification, a "Verification" / "How to verify" heading wins first; otherwise it picks up sentences containing words like "verify" or "check" from the prose.&lt;/p&gt;

&lt;p&gt;Finally, if the prose is under 10 lines and there's at most one code block, the input is short enough already and passes through unchanged. Restructuring a short answer just costs the reader.&lt;/p&gt;

&lt;p&gt;All of these decisions are covered by tests. Twelve of them run in CI on every commit (Python 3.11-3.13).&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;The Markdown parser is a collection of regexes, not a full CommonMark implementation.&lt;/p&gt;

&lt;p&gt;Table rows are treated as prose, so a table-heavy answer can produce an odd conclusion.&lt;/p&gt;

&lt;p&gt;Also, the tool only shortens how the document looks. It can't judge whether the content is correct. A wrong answer comes out as a shorter, wrong answer. If you are using this for study, run the trimmed code yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  Measured
&lt;/h2&gt;

&lt;p&gt;On my own sample set, an 88-line English answer came out as 46 lines, and a 57-line Japanese answer as 26 lines. Both in 0.02 seconds.&lt;/p&gt;

&lt;p&gt;Feed it an answer that is already to the point and nothing happens — it comes back unchanged. That's the spec.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing
&lt;/h2&gt;

&lt;p&gt;When I use AI for learning, I realized I was losing time on reading, not thinking. This CLI is my one-lane fix for the "read it again" tax.&lt;/p&gt;

&lt;p&gt;It's a personal OSS project with no warranty, but if something breaks, an issue is welcome.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/concise-md" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/concise-md&lt;/a&gt;&lt;/p&gt;

</description>
      <category>ai</category>
      <category>cli</category>
      <category>productivity</category>
      <category>tools</category>
    </item>
    <item>
      <title>Do agents survive a crash, and why does an LLM retry execute the same side effect twice? 34 runs measured</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Wed, 23 Sep 2026 15:28:32 +0000</pubDate>
      <link>https://dev.to/sunnydachs/do-agents-survive-a-crash-and-why-does-an-llm-retry-execute-the-same-side-effect-twice-34-runs-52gd</link>
      <guid>https://dev.to/sunnydachs/do-agents-survive-a-crash-and-why-does-an-llm-retry-execute-the-same-side-effect-twice-34-runs-52gd</guid>
      <description>&lt;p&gt;When you hand an agent work with external effects, two worries always come up.&lt;/p&gt;

&lt;p&gt;First: &lt;strong&gt;when the process dies, what happens to the work in progress?&lt;/strong&gt; An agent paused at an approval gate crashes — can it resume, or does everything restart from scratch?&lt;/p&gt;

&lt;p&gt;Second: &lt;strong&gt;when an LLM retries, does it execute the same side effect twice?&lt;/strong&gt; Retrying a send or a data mutation carries a real double-execution risk.&lt;/p&gt;

&lt;p&gt;My previous article measured HITL approval, audit trails, and structured output across three frameworks. It recorded a failure where Strands double-fired a &lt;code&gt;publish&lt;/code&gt; call with the identical draft. This article measures what comes next: 34 more runs, same task, same model, same recorder proxy.&lt;/p&gt;

&lt;p&gt;Repo (all code, traces, and analysis scripts open):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(Read the previous article here. &lt;a href="https://dev.to/sunnydachs/what-happens-when-enterprise-requirements-hit-strands-langgraph-and-crewai-45-runs-measured-ocg"&gt;here&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The experiment design
&lt;/h2&gt;

&lt;p&gt;Same task, same model, same recorder proxy: 3 frameworks x 3 cells x multiple runs.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[agent] → [approval gate] → [publish (destructive)]
      ↑               ↑
  crash it here    pauses waiting
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The three cells:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cell A — crash recovery&lt;/strong&gt;: SIGKILL a process paused at the approval gate; can a NEW process resume from where it stopped?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cell B — idempotency&lt;/strong&gt;: call &lt;code&gt;publish&lt;/code&gt; under 3 key strategies (position key / content hash / no key) and count duplicate executions on retry&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Cell C — audit under retry&lt;/strong&gt;: score whether an auditor reading the traces alone can detect the duplicate&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The three idempotency key strategies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;position key:   {workflow}:{step}:{tool}
content hash:   sha256(the raw arguments)
no key:         nothing
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Cell A: crash recovery — a durable checkpointer is the difference between resume and redo
&lt;/h2&gt;

&lt;p&gt;SIGKILL the agent while it waits for approval, then resume in a new process:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Persistence&lt;/th&gt;
&lt;th&gt;Resume time&lt;/th&gt;
&lt;th&gt;State survived&lt;/th&gt;
&lt;th&gt;LLM calls to resume&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph (durable checkpointer)&lt;/td&gt;
&lt;td&gt;checkpointer on disk&lt;/td&gt;
&lt;td&gt;0.01-0.02s&lt;/td&gt;
&lt;td&gt;3/3&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;zero&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph (no checkpointer)&lt;/td&gt;
&lt;td&gt;none&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;0/3&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Strands&lt;/td&gt;
&lt;td&gt;none built-in&lt;/td&gt;
&lt;td&gt;4.9s avg&lt;/td&gt;
&lt;td&gt;full re-run&lt;/td&gt;
&lt;td&gt;5.3 avg&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrewAI&lt;/td&gt;
&lt;td&gt;none for agents&lt;/td&gt;
&lt;td&gt;4.2s avg&lt;/td&gt;
&lt;td&gt;full re-run&lt;/td&gt;
&lt;td&gt;2.0 avg&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;LangGraph's durable checkpointer persists the graph state to disk even while the process is dead. The new process restores to that position in 0.01s — with &lt;strong&gt;zero LLM calls&lt;/strong&gt;. The difference between &lt;em&gt;resume&lt;/em&gt; and &lt;em&gt;redo&lt;/em&gt; is only whether the state lives outside the process.&lt;/p&gt;

&lt;p&gt;Without a checkpointer, an identical-looking "resume" is a full re-run: Strands runs its average 5.3 LLM calls again and pays the full token cost a second time.&lt;/p&gt;

&lt;h3&gt;
  
  
  What if the crash happens before the first checkpoint?
&lt;/h3&gt;

&lt;p&gt;LangGraph has a known issue here (&lt;a href="https://github.com/langchain-ai/langgraph/issues/8764" rel="noopener noreferrer"&gt;#8764&lt;/a&gt;): if the process dies before the first checkpoint is persisted, recovery may find no checkpoint and no record that the run was ever accepted. On the version I tested, resuming the empty thread &lt;strong&gt;succeeded without raising&lt;/strong&gt; — so the behavior is version-dependent. Don't rely on the error either way; keep an external acceptance ledger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cell B: idempotency — the content-hash key silently fails exactly when the model rewords
&lt;/h2&gt;

&lt;p&gt;Average duplicate executions when an LLM retry re-calls &lt;code&gt;publish&lt;/code&gt;:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Key strategy&lt;/th&gt;
&lt;th&gt;Same-args retry&lt;/th&gt;
&lt;th&gt;Reworded retry&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Position key&lt;/td&gt;
&lt;td&gt;1 dup, all deduped&lt;/td&gt;
&lt;td&gt;3 dups, 33% deduped + rest rejected as caller bug&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Content hash&lt;/td&gt;
&lt;td&gt;1 dup, all deduped&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;1 dup, 0% deduped — it slipped through&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;No key&lt;/td&gt;
&lt;td&gt;1.33 dups, 0% deduped&lt;/td&gt;
&lt;td&gt;1 dup, 0% deduped&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;This is the core result. &lt;strong&gt;The content-hash key silently fails the moment the model rewords the arguments on retry.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The reason is simple. An LLM retry does not replay the saved HTTP request. It &lt;strong&gt;reasons again&lt;/strong&gt; from a context that now includes the timeout error, and emits a new tool call. The arguments get reworded, the order changes, fields appear. With &lt;code&gt;sha256(args)&lt;/code&gt; as the key, the retry produces a different hash, sails past the dedup check, and executes the side effect a second time.&lt;/p&gt;

&lt;p&gt;The position key (&lt;code&gt;{workflow}:{step}:{tool}&lt;/code&gt;) identifies the &lt;strong&gt;intent&lt;/strong&gt; — where the call sits in the workflow — not the bytes. The same position with the same operation yields the same key no matter how the arguments change.&lt;/p&gt;

&lt;p&gt;One more measurement: every duplicated publish carried a &lt;strong&gt;different tool_call ID on the wire&lt;/strong&gt;. Nothing at the protocol layer detects "this is the second time for this operation." Detection lives at the recording level only.&lt;/p&gt;

&lt;p&gt;Note: the position-key strategy rejects "same position, different arguments" calls as a caller bug (2.67 of the runs here). That is by design — it flags intent drift instead of letting the key be reused.&lt;/p&gt;

&lt;h2&gt;
  
  
  Cell C: audit under retry — the trace-only auditor
&lt;/h2&gt;

&lt;p&gt;Can an auditor reading the traces alone recover these four facts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Decision rationale is readable: 100% in all cells&lt;/li&gt;
&lt;li&gt;The duplicate is detectable: 100% in all cells&lt;/li&gt;
&lt;li&gt;Dedup is provable (which attempt was deduped, and why): 100% in all cells&lt;/li&gt;
&lt;li&gt;The retry directive is visible: 100% in all cells&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;But this is only because &lt;strong&gt;the recording is at the wire level&lt;/strong&gt;. The proxy keeps every attempt — first call, dedup, caller-bug rejection — as its own record, so the auditor can reconstruct everything.&lt;/p&gt;

&lt;p&gt;Framework-level trace surfaces show none of the double-firing. What matters in audit design is &lt;em&gt;where the evidence lives&lt;/em&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The most important finding: the failure shape changes with the key strategy
&lt;/h2&gt;

&lt;p&gt;Across the 34 runs, each key strategy fails differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Content-hash key&lt;/strong&gt;: fails silently. The hash just changes — no error, dedup passed, and only the side effect runs twice&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Position key&lt;/strong&gt;: fails loudly. "Same position, different arguments" is rejected as a caller bug, so intent drift surfaces as an error&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No key&lt;/strong&gt;: prevents nothing. Every retry executes twice&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;For enterprise use, the silent failure is the scariest class. &lt;strong&gt;The intuition "a hash key makes retries safe" breaks the moment the caller becomes non-deterministic&lt;/strong&gt; — that is the conclusion from these measurements.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;3 runs per cell is directional, not a statistical claim&lt;/li&gt;
&lt;li&gt;The approver is scripted; no real UI or notification flow&lt;/li&gt;
&lt;li&gt;The destructive action is simulated (though whether the gate held is read directly from the recorded traffic)&lt;/li&gt;
&lt;li&gt;Single model. A different model may reword arguments differently&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Everything is open. The README has the commands for all experiments (34 runs here + 72 from the earlier articles):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a personal OSS project — no warranty. Use at your own risk, and issues are welcome.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>llm</category>
      <category>langgraph</category>
      <category>python</category>
    </item>
    <item>
      <title>What happens when enterprise requirements hit Strands, LangGraph, and CrewAI - 45 runs measured</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Mon, 21 Sep 2026 15:46:51 +0000</pubDate>
      <link>https://dev.to/sunnydachs/what-happens-when-enterprise-requirements-hit-strands-langgraph-and-crewai-45-runs-measured-ocg</link>
      <guid>https://dev.to/sunnydachs/what-happens-when-enterprise-requirements-hit-strands-langgraph-and-crewai-45-runs-measured-ocg</guid>
      <description>&lt;p&gt;What happens when enterprise requirements - human approval gates, audit&lt;br&gt;
trails, structured output - hit three agent frameworks? The first article&lt;br&gt;
measured how Strands, LangGraph, and CrewAI differ on a plain task. This one&lt;br&gt;
measures what happens when the task grows up: 45 more runs, same recorder&lt;br&gt;
proxy, same model, same tools.&lt;/p&gt;

&lt;p&gt;The headline finding: the frameworks fail differently. Strands - the&lt;br&gt;
model-driven one - finished with an &lt;strong&gt;empty output&lt;/strong&gt; three times while exiting&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;LangGraph and CrewAI never did.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;(Read the previous article here. &lt;a href="https://dev.to/sunnydachs/i-built-the-same-agent-in-strands-langgraph-and-crewai-and-recorded-every-llm-call-to-see-how-4ae8"&gt;here&lt;/a&gt;.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the enterprise angle
&lt;/h2&gt;

&lt;p&gt;Two walls keep coming up in developer communities when agents touch regulated&lt;br&gt;
work:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Audit&lt;/strong&gt;: "why did the agent make this decision" must be answerable, or the
workflow can't enter regulated territory&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Approval&lt;/strong&gt;: a human must be able to stop an agent before a destructive
action (data mutation, sending, publishing)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The EU AI Act makes automatic logging and retention a legal obligation for&lt;br&gt;
high-risk AI systems. So past the "working demo", these are the two&lt;br&gt;
validations that matter. I ran them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 1: human approval gates (18 runs)
&lt;/h2&gt;

&lt;p&gt;The task: write a news digest, ask a human to approve, and only publish if&lt;br&gt;
approved. Publishing is a simulated destructive action that must never fire&lt;br&gt;
before approval.&lt;/p&gt;

&lt;p&gt;The three frameworks implement the gate differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;LangGraph&lt;/strong&gt;: &lt;code&gt;interrupt()&lt;/code&gt; suspends the whole graph; &lt;code&gt;Command(resume=...)&lt;/code&gt;
continues it after the decision&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrewAI&lt;/strong&gt;: &lt;code&gt;Task(human_input=True)&lt;/code&gt; - the crew pauses for console feedback
after the task completes&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Strands&lt;/strong&gt;: prompt-only ("ask the reviewer before publishing") - the
model-driven loop is the design&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Results:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;LangGraph&lt;/strong&gt; suspended 6/6 runs and resumed in &lt;strong&gt;0.0s&lt;/strong&gt; - the checkpointer&lt;br&gt;
restores state without re-executing. Reject routes away from the publish node&lt;br&gt;
via an edge condition, so the gate is enforced by the graph's structure, not&lt;br&gt;
by the model's good behavior.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strands&lt;/strong&gt; respected the order too: ask -&amp;gt; check -&amp;gt; publish 3/3, and never&lt;br&gt;
published on reject. But one run called &lt;code&gt;publish_article&lt;/code&gt; &lt;strong&gt;twice with the&lt;br&gt;
identical draft&lt;/strong&gt;. The prompt was followed; the model just double-fired the&lt;br&gt;
destructive action. In a model-driven design, a second execution after&lt;br&gt;
approval is a real risk.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CrewAI&lt;/strong&gt; is the simplest shape: 1 call for approve, 2 for reject (the&lt;br&gt;
feedback re-runs the task). One operational gotcha measured along the way:&lt;br&gt;
returning the same rejection on every prompt spins an infinite loop - 131 LLM&lt;br&gt;
calls with the prompt growing from 240 to 6,561 tokens. CrewAI re-runs and&lt;br&gt;
re-prompts on every non-empty feedback, so the repetition policy is the&lt;br&gt;
caller's responsibility.&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 2: audit-trail reconstruction (36 runs analyzed)
&lt;/h2&gt;

&lt;p&gt;The regulated question is "why did the agent decide this". Because every run&lt;br&gt;
goes through the same recorder proxy, the traces have one shape - so I scored&lt;br&gt;
whether an auditor can recover the seven audit-relevant facts (decision&lt;br&gt;
rationale, tool call order, tool arguments, model identity, and more) from&lt;br&gt;
each framework's traces:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Framework&lt;/th&gt;
&lt;th&gt;Rationale&lt;/th&gt;
&lt;th&gt;Tool order&lt;/th&gt;
&lt;th&gt;Args&lt;/th&gt;
&lt;th&gt;Silent failures&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Strands&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;LangGraph&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CrewAI&lt;/td&gt;
&lt;td&gt;100%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;50%&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Strands is model-driven, so everything the model saw and reasoned about stays&lt;br&gt;
in the trace - the strongest audit story of the three. The flip side is&lt;br&gt;
exactly those 2 silent failures.&lt;/p&gt;

&lt;p&gt;LangGraph's 0% is not a defect: its tool calls live in code, not on the wire.&lt;br&gt;
Read the code and you know the order; read only the trace and you don't. That&lt;br&gt;
is the real audit-design trade-off: &lt;strong&gt;where the evidence lives changes with&lt;br&gt;
the framework's control-flow model.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  Experiment 3: structured output (9 runs)
&lt;/h2&gt;

&lt;p&gt;Output the digest as strict JSON with exactly 4 keys (&lt;code&gt;summary&lt;/code&gt;,&lt;br&gt;
&lt;code&gt;word_count&lt;/code&gt;, &lt;code&gt;topics&lt;/code&gt;, &lt;code&gt;publish_ready&lt;/code&gt;). All three frameworks hit 100%&lt;br&gt;
compliance, and &lt;code&gt;word_count&lt;/code&gt; matched the actual summary length in every run -&lt;br&gt;
putting the count inside the schema makes the model's self-verification&lt;br&gt;
effective. Strands ran a validate loop averaging 2 calls (4 revisions in one&lt;br&gt;
run).&lt;/p&gt;

&lt;h2&gt;
  
  
  The most important finding: they fail differently
&lt;/h2&gt;

&lt;p&gt;Across all 45 runs, only Strands finished with an &lt;strong&gt;empty output&lt;/strong&gt; three&lt;br&gt;
times. The model built the complete result, handed it to the validation tool,&lt;br&gt;
and then emitted nothing as the final answer. The run exits 0 - it looks&lt;br&gt;
successful. You only catch it by reading the trace.&lt;/p&gt;

&lt;p&gt;LangGraph and CrewAI: zero. In a pipeline design, the output node IS the&lt;br&gt;
deliverable, so an empty answer is structurally hard to produce.&lt;/p&gt;

&lt;p&gt;For enterprise use, this is the scariest class of failure: not an error that&lt;br&gt;
stops the run, but a success-shaped empty result that breaks everything&lt;br&gt;
downstream. (The empty outputs were recoverable - the model passes its full&lt;br&gt;
result to the tool as an argument, so the trace holds it.)&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;p&gt;One model, 3 runs per cell - directional, not a definitive ranking. The human&lt;br&gt;
is scripted; no real UI or notification flow. The destructive action is&lt;br&gt;
simulated - though whether the gate held is read directly from the recorded&lt;br&gt;
traffic, which is the part that's solid.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;

&lt;p&gt;Everything is open. The repo README has the commands for all five experiments&lt;br&gt;
(72 recorded runs total, one proxy in front of every framework - that's the&lt;br&gt;
whole foundation):&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a personal OSS project - no warranty. Use at your own risk, and&lt;br&gt;
issues are welcome.&lt;/em&gt;&lt;/p&gt;




&lt;p&gt;Cover image: generated with a local flux-schnell pipeline.&lt;/p&gt;

</description>
      <category>agents</category>
      <category>ai</category>
      <category>llm</category>
      <category>python</category>
    </item>
    <item>
      <title>I built the same agent in Strands, LangGraph, and CrewAI — and recorded every LLM call to see how they actually differ</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Mon, 21 Sep 2026 05:47:42 +0000</pubDate>
      <link>https://dev.to/sunnydachs/i-built-the-same-agent-in-strands-langgraph-and-crewai-and-recorded-every-llm-call-to-see-how-4ae8</link>
      <guid>https://dev.to/sunnydachs/i-built-the-same-agent-in-strands-langgraph-and-crewai-and-recorded-every-llm-call-to-see-how-4ae8</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Which framework should I actually use? Every comparison article has an&lt;br&gt;
opinion. Almost none of them has data.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;So I built the same agent three times — once in &lt;strong&gt;Strands&lt;/strong&gt;, once in&lt;br&gt;
&lt;strong&gt;LangGraph&lt;/strong&gt;, once in &lt;strong&gt;CrewAI&lt;/strong&gt; — ran 27 executions, and routed every&lt;br&gt;
single LLM call through a local recorder proxy so the logs are directly&lt;br&gt;
comparable. The frameworks write different log formats, different trace&lt;br&gt;
shapes, different everything. A one-file proxy in front of all of them fixed&lt;br&gt;
that.&lt;/p&gt;

&lt;p&gt;The headline finding: LangGraph's explicit verify/revise loop cut output&lt;br&gt;
variance by &lt;strong&gt;77%&lt;/strong&gt; (word-count spread 13 -&amp;gt; 3) — at 2.5x the tokens and 2.5x&lt;br&gt;
the latency. Explicit control buys determinism, and you pay for it in tokens&lt;br&gt;
and latency.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The task
&lt;/h2&gt;

&lt;p&gt;A tech-news digest agent, identical across all three frameworks:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;collect 5 headlines via a &lt;code&gt;fetch_headlines&lt;/code&gt; tool&lt;/li&gt;
&lt;li&gt;write a ~100-word digest&lt;/li&gt;
&lt;li&gt;verify the word count via a &lt;code&gt;word_count&lt;/code&gt; tool, revising if out of band&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The tools are deterministic and local — no network, no LLM inside them —&lt;br&gt;
because the thing being measured is the framework's behavior, not the tool's.&lt;br&gt;
Same model behind the proxy for all three runs.&lt;/p&gt;

&lt;h2&gt;
  
  
  How the three frameworks differ (in code)
&lt;/h2&gt;

&lt;p&gt;The philosophy split is real, and it shows up in the implementation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Strands&lt;/strong&gt; (model-driven): you hand the model two tools and a system
prompt. The LLM decides which tool to call, in what order, and when it's
done. My implementation is 78 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;LangGraph&lt;/strong&gt; (graph-driven): you define a typed state, every node, every
edge, and the loop condition. The model only makes decisions &lt;em&gt;inside&lt;/em&gt;
nodes. 115 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CrewAI&lt;/strong&gt; (role-based): agents are team members with role/goal/backstory;
the Crew orchestrates the handoff. 110 lines.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The dependency weight differs too: Strands 262MB (81 packages), LangGraph&lt;br&gt;
71MB (45), CrewAI 699MB (142). CrewAI's weight is the flip side of&lt;br&gt;
"fastest to prototype."&lt;/p&gt;

&lt;h2&gt;
  
  
  The observability design (the part that made this honest)
&lt;/h2&gt;

&lt;p&gt;One recorder proxy sits in front of every framework&lt;br&gt;
(&lt;code&gt;proxy/rec_proxy.py&lt;/code&gt;): an HTTP server that forwards to any&lt;br&gt;
OpenAI-compatible endpoint and writes every request/response pair as JSONL —&lt;br&gt;
the full messages the framework sent (system prompt, tool schemas,&lt;br&gt;
conversation history), the raw response (SSE-streamed or JSON), token usage,&lt;br&gt;
latency, and status. The API key is stripped before writing.&lt;/p&gt;

&lt;p&gt;An &lt;code&gt;X-Run-Label&lt;/code&gt; header splits traces per run. Strands and LangChain stream&lt;br&gt;
SSE, so a small reassembler (&lt;code&gt;parse_sse.py&lt;/code&gt;) normalizes streamed responses&lt;br&gt;
into the same shape as non-streamed ones (content / reasoning / tool_calls /&lt;br&gt;
usage).&lt;/p&gt;

&lt;p&gt;Same trace shape across frameworks is what lets you diff messages, tokens,&lt;br&gt;
and latency exactly. Without it, "which framework is slower" is vibes.&lt;/p&gt;

&lt;h2&gt;
  
  
  The numbers (27 runs, 3 frameworks x 3 scenarios x 3 runs)
&lt;/h2&gt;

&lt;p&gt;Three scenarios:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;base&lt;/strong&gt; — 80-120 word band (the original task)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;tight&lt;/strong&gt; — 95-105 words, which forces the verify/revise loop to fire&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;drift&lt;/strong&gt; — the &lt;code&gt;word_count&lt;/code&gt; tool's argument renamed (&lt;code&gt;text&lt;/code&gt; -&amp;gt; &lt;code&gt;content&lt;/code&gt;),
testing how each framework copes with schema change&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Word-count spread across 3 runs (max - min, lower = more stable):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Scenario&lt;/th&gt;
&lt;th&gt;Strands&lt;/th&gt;
&lt;th&gt;LangGraph&lt;/th&gt;
&lt;th&gt;CrewAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;base&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;13&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;0&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;tight&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;3&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;drift&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;16&lt;/td&gt;
&lt;td&gt;0&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Strands&lt;/th&gt;
&lt;th&gt;LangGraph&lt;/th&gt;
&lt;th&gt;CrewAI&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;LLM calls&lt;/td&gt;
&lt;td&gt;3-5 (adaptive)&lt;/td&gt;
&lt;td&gt;1 (base) / 2 (tight)&lt;/td&gt;
&lt;td&gt;4 fixed&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total tokens (base)&lt;/td&gt;
&lt;td&gt;2,370&lt;/td&gt;
&lt;td&gt;2,007&lt;/td&gt;
&lt;td&gt;2,532&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Total latency&lt;/td&gt;
&lt;td&gt;4.2s&lt;/td&gt;
&lt;td&gt;4.7s&lt;/td&gt;
&lt;td&gt;3.8s&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What the traces actually show
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;LangGraph&lt;/strong&gt; in base mode made a single LLM call and wrote the draft in one&lt;br&gt;
shot. Word-count spread: 13 words. Switch to tight, and the explicit&lt;br&gt;
verify/revise loop fires: spread drops to 3 (−77%), tokens go 2,007 -&amp;gt; 5,262,&lt;br&gt;
latency 4.7s -&amp;gt; 11.8s. The graph structure &lt;em&gt;guarantees&lt;/em&gt; the loop runs — that&lt;br&gt;
is the product you're buying, and the price is 2.5x on everything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;CrewAI&lt;/strong&gt; produced byte-identical output across all 3 base and drift runs&lt;br&gt;
(96/96/96 words). temperature=0 plus the role prompt dominates. The flip&lt;br&gt;
side: the call structure is always 4 calls, fixed. The framework that "just&lt;br&gt;
ships it" also just repeats it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strands&lt;/strong&gt; self-corrected inside its own loop. In one tight run the model&lt;br&gt;
wrote a 77-word draft, called &lt;code&gt;check_word_count&lt;/code&gt;, reasoned "77 is under 95,&lt;br&gt;
I need to expand it," revised itself to 103, and verified again — five LLM&lt;br&gt;
calls, variable depth per run ([5, 3, 4]). The model deciding when to stop&lt;br&gt;
is part of the design, and the call count being adaptive is what you get for&lt;br&gt;
trusting it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema drift: models adapted perfectly (this time)
&lt;/h2&gt;

&lt;p&gt;The rename was absorbed 100%: all three frameworks' models called the tool&lt;br&gt;
with the &lt;strong&gt;new&lt;/strong&gt; argument name, zero wrong-arg calls, no error recovery&lt;br&gt;
triggered. Worth being precise about what this does and doesn't show: a&lt;br&gt;
one-argument rename is the gentlest possible schema change. Type changes,&lt;br&gt;
removed arguments, or changed return shapes would likely break the&lt;br&gt;
model-driven side (which trusts its prompt over the wire), while LangGraph&lt;br&gt;
would be immune — its tool calls live in code, not in a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;3 runs per cell is a trend check, not a statistical claim. Standard
guidance treats ~30 runs as the floor for median estimation.&lt;/li&gt;
&lt;li&gt;One task, one tool. This is exactly where model-driven frameworks shine.
LangGraph's graph pays off in complex branching / approval / parallel
workflows that I did not test.&lt;/li&gt;
&lt;li&gt;One model. Reasoning volume, tool-call tendencies, and output variance are
model-dependent and will change with a different model.&lt;/li&gt;
&lt;li&gt;Dependency footprints move fast; numbers are from the initial install
snapshot.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Reproduce it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;python3 runs/run_matrix.py       &lt;span class="c"&gt;# 27 runs in ~270s&lt;/span&gt;
python3 runs/analyze_matrix.py   &lt;span class="c"&gt;# -&amp;gt; artifacts/matrix_report.json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The repo README has the full setup (three venvs, the recorder proxy, the&lt;br&gt;
per-framework run commands). One proxy in front of everyone is the whole&lt;br&gt;
trick — when the frameworks write different log formats, a same-shape&lt;br&gt;
recorder is the simplest honest answer.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/agent-framework-showdown" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/agent-framework-showdown&lt;/a&gt;&lt;/p&gt;

</description>
      <category>aiagents</category>
      <category>langgraph</category>
      <category>crewai</category>
      <category>python</category>
    </item>
    <item>
      <title>Your GDScript arrays are slower than you think? — I built a CLI that detects the pitfalls and benchmarks them</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Sun, 20 Sep 2026 02:33:36 +0000</pubDate>
      <link>https://dev.to/sunnydachs/your-gdscript-arrays-are-slower-than-you-think-i-built-a-cli-that-detects-the-pitfalls-and-3ok3</link>
      <guid>https://dev.to/sunnydachs/your-gdscript-arrays-are-slower-than-you-think-i-built-a-cli-that-detects-the-pitfalls-and-3ok3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;"Why is my loop stuttering? I'm not even doing anything heavy here."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Every Godot tutorial tells you to just use arrays. Nobody tells you that an&lt;br&gt;
untyped array holds Variants, that a sequential-key Dictionary is ~2x slower&lt;br&gt;
than a plain Array, or that the official docs contradict themselves about&lt;br&gt;
packed arrays. This is about &lt;strong&gt;gd-bench&lt;/strong&gt;, a CLI I built that scans your&lt;br&gt;
&lt;code&gt;.gd&lt;/code&gt; files for array-type performance pitfalls and generates runnable Godot&lt;br&gt;
micro-benchmarks for each one — with zero dependencies and no LLM anywhere.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/gd-bench" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/gd-bench&lt;/a&gt;&lt;/p&gt;
&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;Point it at a Godot project. It scans every &lt;code&gt;.gd&lt;/code&gt; file and reports where the&lt;br&gt;
array-type choice is costing you performance, then writes a runnable&lt;br&gt;
benchmark script per finding so you can measure before you rewrite.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;gd-bench

gd-bench &lt;span class="nb"&gt;.&lt;/span&gt;              &lt;span class="c"&gt;# scan the current project&lt;/span&gt;
gd-bench &lt;span class="nb"&gt;.&lt;/span&gt; &lt;span class="nt"&gt;--gen-bench&lt;/span&gt;  &lt;span class="c"&gt;# also generate Godot micro-benchmarks per finding&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output (a small demo project, 7 files):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;game&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;  &lt;span class="err"&gt;🐢&lt;/span&gt; &lt;span class="n"&gt;UNTYPED&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;inventory&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;            &lt;span class="c1"&gt;# untyped array — Variant soup&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
&lt;span class="n"&gt;game&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;  &lt;span class="err"&gt;🔧&lt;/span&gt; &lt;span class="n"&gt;TYPED&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;scores&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="kt"&gt;int&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="c1"&gt;# typed array — fine but Packed is faster&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;
&lt;span class="n"&gt;game&lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="n"&gt;main&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gd&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;  &lt;span class="err"&gt;✅&lt;/span&gt; &lt;span class="n"&gt;PACKED&lt;/span&gt; &lt;span class="n"&gt;ARRAY&lt;/span&gt;
    &lt;span class="k"&gt;var&lt;/span&gt; &lt;span class="n"&gt;points&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PackedVector2Array&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;PackedVector2Array&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;  &lt;span class="c1"&gt;# already optimal&lt;/span&gt;
    &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt;

&lt;span class="n"&gt;summary&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;"typed_array"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"dict_as_array"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"untyped_array"&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="s2"&gt;"packed_array"&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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reports 4 kinds of findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UNTYPED ARRAY&lt;/strong&gt; — &lt;code&gt;var x = [1, 2, 3]&lt;/code&gt; holds Variants; slowest to iterate&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;TYPED ARRAY&lt;/strong&gt; — &lt;code&gt;Array[int]&lt;/code&gt; gives compile-time checks but is still
Variant-backed; &lt;code&gt;PackedInt32Array&lt;/code&gt; is faster for the same element type&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PACKED ARRAY&lt;/strong&gt; — contiguous memory, the fast path (reported as ✅)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DICT AS ARRAY&lt;/strong&gt; — &lt;code&gt;var d = {}&lt;/code&gt; filled with sequential keys is ~2x slower
and uses about half the memory again versus a plain Array&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;A static type checker will never tell you which of these is actually slower&lt;br&gt;
in &lt;em&gt;your&lt;/em&gt; loop — and the official docs can't agree either. Only a&lt;br&gt;
measurement can.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why deliberately generate benchmarks instead of just linting
&lt;/h2&gt;

&lt;p&gt;This was the core design decision. A linter could just say "use&lt;br&gt;
&lt;code&gt;PackedInt32Array&lt;/code&gt; here" and be done. But I deliberately built it as&lt;br&gt;
&lt;strong&gt;scanner + benchmark generator&lt;/strong&gt; instead. Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The docs are contradictory.&lt;/strong&gt; The GDScript reference (still, as of
issue &lt;a href="https://github.com/godotengine/godot-docs/issues/10300" rel="noopener noreferrer"&gt;#10300&lt;/a&gt;
being discussed) says packed arrays are &lt;em&gt;slower&lt;/em&gt; than generic arrays;
the class reference says they are &lt;em&gt;faster&lt;/em&gt;. A tool that parrots either
line would be wrong half the time. A benchmark settles it per call site.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Performance advice without a measurement is folklore.&lt;/strong&gt; The same
operation (append + iterate) can flip the winner depending on element
type and size. Generating the benchmark next to the finding makes
"measure before you rewrite" the default workflow, not a discipline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Generated benchmarks are honest about what they test.&lt;/strong&gt; Each script
measures exactly two data structures doing exactly one operation — no
framework, no warm-up noise, no statistical smoothing. If the number
looks wrong, the script is 40 lines you can read in a minute.&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;Deterministic work deserves deterministic tools.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;h2&gt;
  
  
  The rule: no rewrite without a benchmark run
&lt;/h2&gt;

&lt;p&gt;The detail I obsessed over: the tool must never hand you a rewrite suggestion&lt;br&gt;
it hasn't equipped you to verify. So the rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Allowed&lt;/strong&gt;: findings with a generated benchmark script next to them&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Forbidden&lt;/strong&gt;: "packed_array" findings get no benchmark — they're already
the fast path, and benchmarking the optimal case just adds noise&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That single line (&lt;code&gt;書き換え前に必ず計測&lt;/code&gt; / "always measure before rewriting")&lt;br&gt;
is what keeps the tool from becoming another "just use X" voice. The&lt;br&gt;
generated script is plain GDScript (&lt;code&gt;extends SceneTree&lt;/code&gt;, runs headless) so&lt;br&gt;
you can run it in CI or on a machine without the editor:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;godot &lt;span class="nt"&gt;--headless&lt;/span&gt; &lt;span class="nt"&gt;--script&lt;/span&gt; gd-bench-out/bench_gd_untyped_array_4.gd
&lt;span class="c"&gt;# gd-bench: untyped_array&lt;/span&gt;
&lt;span class="c"&gt;#   untyped_array: 8231 usec&lt;/span&gt;
&lt;span class="c"&gt;#   typed_array: 5120 usec&lt;/span&gt;
&lt;span class="c"&gt;#   ratio: 1.61x&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing against real-world patterns
&lt;/h2&gt;

&lt;p&gt;Not a toy example — the detection patterns come from the same shapes real&lt;br&gt;
Godot projects and issues use:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;23 unit tests covering all 4 finding kinds, comment lines, non-sequential
Dictionary keys (not flagged), and end-to-end CLI runs&lt;/li&gt;
&lt;li&gt;The demo project above caught 8 findings across 5 files — including two
&lt;code&gt;DICT AS ARRAY&lt;/code&gt; cases that were doing 1000 sequential-key inserts per frame&lt;/li&gt;
&lt;li&gt;The scanner correctly skipped its own generated benchmarks' comment lines
and only flagged real declarations&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I also scanned 5 popular open-source Godot projects with it&lt;br&gt;
(GodotProjectZero, both GDQuest demos, godot-open-rts, Advanced-Movement-System).&lt;br&gt;
That scan found real findings — &lt;code&gt;godot-open-rts&lt;/code&gt; has 30 untyped arrays in its&lt;br&gt;
&lt;code&gt;source/&lt;/code&gt; — and it caught the scanner lying: every &lt;code&gt;DICT AS ARRAY&lt;/code&gt; hit on&lt;br&gt;
String keys or Node keys was a false positive (e.g. &lt;code&gt;units_synced[unit] = 1&lt;/code&gt;&lt;br&gt;
where &lt;code&gt;unit&lt;/code&gt; is a Node — a legitimate keyed Dictionary). v0.1.1 tightened the&lt;br&gt;
rule: only int literals and &lt;code&gt;range()&lt;/code&gt; loop variables are flagged, everything&lt;br&gt;
else is left alone. One real finding was sacrificed for that; documented.&lt;/p&gt;

&lt;p&gt;The real win wasn't just "it works" — it's that the tool's advice and the&lt;br&gt;
docs' advice can be &lt;em&gt;compared&lt;/em&gt; in one command, which is exactly what the&lt;br&gt;
godot-docs issue is asking for.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Line-based detection.&lt;/strong&gt; GDScript has no stable public AST, so this is
regex-based pattern matching. It catches the common declaration shapes
(&lt;code&gt;var&lt;/code&gt; lines, &lt;code&gt;{}&lt;/code&gt; + indexed assignment); it does not catch arrays built
through multi-line expressions or passed as literals into function calls.
The &lt;code&gt;for i in N:&lt;/code&gt; int-loop form is deliberately not treated as sequential
(it's indistinguishable from element enumeration) — a documented false negative.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Benchmarks measure one operation.&lt;/strong&gt; append + iterate-sum. Real frames do
more (splice, sort, resize) — the generated script is a starting point,
read it and extend it for your loop's actual shape.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No Godot required to scan, required to bench.&lt;/strong&gt; The scanner runs
anywhere Python runs; running the generated benchmark needs a Godot 4
binary (headless is fine).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Frame time scales with your worst inner loop. Array-type choice is exactly&lt;br&gt;
the class of problem where "scanner + generated measurement" earns its keep:&lt;br&gt;
the advice is cheap to give, but only a benchmark tells you if it was true&lt;br&gt;
for your code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/gd-bench" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/gd-bench&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a personal OSS project with no warranty. If you hit bugs or have&lt;br&gt;
suggestions, GitHub issues are the best way to reach me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cli</category>
      <category>gamedev</category>
      <category>performance</category>
      <category>tools</category>
    </item>
    <item>
      <title>Your OpenAPI spec is a contract. Nobody is checking it. I built a CLI that detects spec↔code drift with zero dependencies.</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Sat, 19 Sep 2026 07:13:34 +0000</pubDate>
      <link>https://dev.to/sunnydachs/your-openapi-spec-is-a-contract-nobody-is-checking-it-i-built-a-cli-that-detects-spec-code-drift-23ah</link>
      <guid>https://dev.to/sunnydachs/your-openapi-spec-is-a-contract-nobody-is-checking-it-i-built-a-cli-that-detects-spec-code-drift-23ah</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;"The generated client keeps calling DELETE /items/{id}."&lt;br&gt;
"The server returns 405 Method Not Allowed."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;An OpenAPI spec is not just documentation. SDKs, frontend types, API docs, mock servers — everything is generated &lt;em&gt;from&lt;/em&gt; the spec. When the implementation drifts from it, &lt;strong&gt;everything generated starts lying quietly&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;This is about &lt;strong&gt;oas-drift&lt;/strong&gt;, a CLI I built that detects that drift — with &lt;strong&gt;zero dependencies&lt;/strong&gt; (Python 3.11+ standard library only, no LLM).&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/oas-drift" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/oas-drift&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;You give it an OpenAPI JSON spec and a Python codebase. It finds three classes of drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SPEC ONLY&lt;/strong&gt; — defined in the spec, but no matching route in code&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;CODE ONLY&lt;/strong&gt; — route exists in code, but is not in the spec&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;METHOD MISMATCH&lt;/strong&gt; — path matches, but the HTTP method differs
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scan the current directory against a spec (read-only)&lt;/span&gt;
oas-drift &lt;span class="nt"&gt;--spec&lt;/span&gt; openapi.json

&lt;span class="c"&gt;# scan a specific root, machine-readable output&lt;/span&gt;
oas-drift &lt;span class="nt"&gt;--spec&lt;/span&gt; openapi.json ./src &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Here is a real run against a deliberately-drifted demo app:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;oas-drift — scanned src
  spec: 5 endpoint(s) | code: 4 route(s)

/health  ➕ CODE ONLY
    route implemented (GET) but not defined in spec — src/app.py:21
/items/{id}  ⚠️ METHOD MISMATCH
    /items/{id}: in spec but not implemented: DELETE; implemented but not in spec: POST — src/app.py:17
/users/{id}  ⚠️ METHOD MISMATCH
    /users/{id}: in spec but not implemented: DELETE — src/app.py:13
/admin/stats  ⬜ SPEC ONLY
    defined in spec (GET) but no matching route in codebase
/items  ⬜ SPEC ONLY
    defined in spec (GET) but no matching route in codebase

summary: {"code_only": 1, "method_mismatch": 2, "spec_only": 2} | ok: 2
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;"The spec is the contract" only works if &lt;em&gt;someone&lt;/em&gt; checks both sides. Code review sees the diff against the last commit — not against a spec written three months ago.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why it's a detector, not a gate
&lt;/h2&gt;

&lt;p&gt;This was the core design decision. Most CI-facing drift tools fail the build when they find anything. oas-drift's exit code is &lt;code&gt;0&lt;/code&gt; either way. Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Adoption survives.&lt;/strong&gt; A tool that fails your build on day one gets removed on day two. A detector stays.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Drift has priorities.&lt;/strong&gt; A &lt;code&gt;/health&lt;/code&gt; endpoint missing from the spec is usually fine. A METHOD MISMATCH on a payment route is not. Which drift fails the build is policy — the tool shouldn't decide that for you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Fully deterministic.&lt;/strong&gt; It parses sources with Python's &lt;code&gt;ast&lt;/code&gt; module — never imports, never executes, never writes. Same input → same report, always.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you &lt;em&gt;do&lt;/em&gt; want to fail on specific statuses, wire it into CI with &lt;code&gt;--json&lt;/code&gt; and &lt;code&gt;jq&lt;/code&gt; — the report is machine-readable by design.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Same principles as the sibling tools I shipped this month — &lt;strong&gt;doc-drift&lt;/strong&gt; (README↔code) and &lt;strong&gt;plan-drift&lt;/strong&gt; (tracking plan↔code). Deterministic work deserves deterministic tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The rule: paths match literally
&lt;/h2&gt;

&lt;p&gt;The detail I obsessed over: path parameters and router prefixes. The rule:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;/users/{id}&lt;/code&gt; in the spec matches &lt;code&gt;/users/{id}&lt;/code&gt; in code — and only that.&lt;/strong&gt;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Router prefixes are NOT resolved.&lt;/strong&gt; A router with &lt;code&gt;prefix="/items"&lt;/code&gt; serving &lt;code&gt;/{id}&lt;/code&gt; does not match a spec's &lt;code&gt;/items/{id}&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I validated this against a real, widely-used codebase — the backend of FastAPI's official &lt;strong&gt;full-stack-fastapi-template&lt;/strong&gt; (25 files, 14 paths, 23 routes detected). Scanning it with a spec written in prefixed paths produces exactly the false-positive pair this rule predicts:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/items/{id}  ⬜ SPEC ONLY
    defined in spec (DELETE, GET, PUT) but no matching route in codebase
/{id}  ➕ CODE ONLY
    route implemented (DELETE, GET, PUT) but not defined in spec — backend/app/api/routes/items.py:48, ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two names for the same route. oas-drift doesn't guess — it reports what literally exists. If your project uses router prefixes, normalize the spec side first (the surest source is the &lt;code&gt;/openapi.json&lt;/code&gt; your app actually serves).&lt;/p&gt;

&lt;p&gt;And the important detection works: flipping the implemented &lt;code&gt;POST /login/access-token&lt;/code&gt; to &lt;code&gt;PUT&lt;/code&gt; in a test spec gets reported precisely:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;/login/access-token  ⚠️ METHOD MISMATCH
    /login/access-token: in spec but not implemented: PUT; implemented but not in spec: POST — backend/app/api/routes/login.py:23
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Testing against real-world data
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;15 pure-function tests&lt;/strong&gt; (no network, no fixtures on disk), all passing. Runtime: 0.04s.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;e2e on a real install path&lt;/strong&gt;: wheel build → install → actual drift detection, verified.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A real codebase&lt;/strong&gt;: FastAPI's official full-stack-fastapi-template backend — the deliberately planted METHOD MISMATCH was caught, and the router-prefix false-positive pair was something &lt;em&gt;I&lt;/em&gt; discovered and documented.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;JSON only&lt;/strong&gt; (OpenAPI 3.x JSON; YAML is a future item).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Literal path matching&lt;/strong&gt; — normalizing router prefixes is on you.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;f-string paths are invisible&lt;/strong&gt; — &lt;code&gt;@app.get(f"/users/{id}")&lt;/code&gt; isn't extracted; string literals only.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Routes and methods only&lt;/strong&gt; — request/response schema comparison is a future item.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are documented in the README. The current version prioritizes minimal, honest detection over coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Specs drift the moment the code moves and nobody updates them. A contract is only a contract if something checks it — &lt;strong&gt;oas-drift&lt;/strong&gt; is that checking part, built read-only, fully deterministic, and dependency-free.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/oas-drift" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/oas-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a personal OSS project with no warranty. If you hit bugs or have suggestions, GitHub issues are the best way to reach me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>api</category>
      <category>openapi</category>
      <category>opensource</category>
    </item>
    <item>
      <title>The tracking plan said "implemented". The dashboard says otherwise. I built a CLI that detects plan↔code drift using only AST.</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Fri, 18 Sep 2026 02:09:41 +0000</pubDate>
      <link>https://dev.to/sunnydachs/the-tracking-plan-said-implemented-the-dashboard-says-otherwise-i-built-a-cli-that-detects-2b8a</link>
      <guid>https://dev.to/sunnydachs/the-tracking-plan-said-implemented-the-dashboard-says-otherwise-i-built-a-cli-that-detects-2b8a</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;"Did shipping auth tracking make it into the release?"&lt;br&gt;
"Yep, it's in."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Two weeks later, someone opens the dashboard and says "…this doesn't look right."&lt;/p&gt;

&lt;p&gt;This is about &lt;strong&gt;plan-drift&lt;/strong&gt;, a CLI I built that detects exactly that gap — the silent drift between your analytics &lt;strong&gt;tracking plan&lt;/strong&gt; (what you intended to measure) and the &lt;strong&gt;actual implementation&lt;/strong&gt; — &lt;strong&gt;without using an LLM at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/plan-drift" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/plan-drift&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;You give it a repository and a tracking plan (a JSON file). It finds four kinds of drift:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;UNEXPECTED EVENT&lt;/strong&gt; — implemented in code, but not in the tracking plan&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UNIMPLEMENTED EVENT&lt;/strong&gt; — in the plan, but no &lt;code&gt;track()&lt;/code&gt; call found&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;PROPERTY MISMATCH&lt;/strong&gt; — event name matches, but its properties diverge (undeclared props, missing required)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;DYNAMIC&lt;/strong&gt; — the event name or properties can't be resolved statically (manual check needed)
&lt;/li&gt;
&lt;/ul&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;&lt;span class="c"&gt;# scan the current repo against your plan (read-only)&lt;/span&gt;
plan-drift &lt;span class="nt"&gt;--plan&lt;/span&gt; tracking-plan.json

&lt;span class="c"&gt;# another path, JSON output&lt;/span&gt;
plan-drift &lt;span class="nt"&gt;--plan&lt;/span&gt; tracking-plan.json ./src &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Output looks like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;plan-drift — scanned /home/dev/myproject
  tracking plan events: 4 | track() calls found: 6 (dynamic: 1)
app/events.py:12  [!] PROPERTY MISMATCH
    event 'Signed Up': undeclared property 'campaign' found in code but not in plan
app/analytics.py:34  [+] UNEXPECTED EVENT
    'Add To Cart' implemented (1 call) but not in the tracking plan
app/analytics.py:56  [ ] UNIMPLEMENTED EVENT
    'Checkout Started' declared in plan but no track() call found
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;blockquote&gt;
&lt;p&gt;"The event shipped but the properties are off" is invisible to IDEs and static analysis. Tracking code is rarely covered by tests. So nobody notices until the numbers look wrong.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why deliberately &lt;em&gt;not&lt;/em&gt; use an LLM
&lt;/h2&gt;

&lt;p&gt;This was the core design decision. LLMs could parse ambiguous tracking code with more "understanding." But I built it on &lt;strong&gt;nothing but Python's standard &lt;code&gt;ast&lt;/code&gt; (Abstract Syntax Tree)&lt;/strong&gt;. Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic.&lt;/strong&gt; Same input → same report, always. You can't put a tool in CI whose verdict changes run to run.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only and safe.&lt;/strong&gt; It never generates or edits code. It parses the syntax tree and compares.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies.&lt;/strong&gt; Python 3.11+ is enough. &lt;code&gt;pip install git+...&lt;/code&gt; and you're done.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Tracking calls like &lt;code&gt;analytics.track(...)&lt;/code&gt; are statically analyzable — an LLM here would add cost, latency, and nondeterminism with no accuracy gain.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;This is one answer to a question I keep coming back to when automating: &lt;em&gt;how much should AI handle?&lt;/em&gt; Deterministic work deserves deterministic tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The design core: check both directions
&lt;/h2&gt;

&lt;p&gt;The thing I cared most about was &lt;strong&gt;checking plan↔implementation in both directions&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;An implemented event NOT in the plan is a dangerous signal&lt;/strong&gt; — could be an unexpected addition, could be a misconfiguration&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A planned event with no implementation is also a problem&lt;/strong&gt; — the classic "the plan got written, nobody shipped it"&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;So the tool cross-references and enumerates &lt;strong&gt;every mismatch&lt;/strong&gt;. Test files (&lt;code&gt;tests.py&lt;/code&gt;, &lt;code&gt;test_*.py&lt;/code&gt;) are excluded, because fixture events showing up as "unplanned implementations" would be pure noise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it fits
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Marketing team writes the plan → devs instantly see how far implementation has come&lt;/li&gt;
&lt;li&gt;Renaming an event or dropping a property without updating the plan → caught immediately&lt;/li&gt;
&lt;li&gt;Wire into CI to warn on critical drift&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The "plan exists but implementation never got updated" problem I've seen at workplaces — this gets you most of the way there.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Dynamic events are reported, not resolved&lt;/strong&gt; — &lt;code&gt;track(event_name, props)&lt;/code&gt; shows up as &lt;code&gt;DYNAMIC&lt;/code&gt; for manual review.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python only for now&lt;/strong&gt; (&lt;code&gt;.py&lt;/code&gt;). JS needs a different parser — planned.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Only key presence is checked, not values&lt;/strong&gt; — if the plan says &lt;code&gt;plan: string&lt;/code&gt; and the code sends an int, that's a future improvement.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;These are documented on the roadmap. The current version prioritizes correctness and low false positives over coverage.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Drift between planning and implementation quietly corrupts your dashboards. &lt;strong&gt;plan-drift&lt;/strong&gt; fights it with a read-only, fully deterministic, LLM-free design — accuracy and reproducibility first.&lt;/p&gt;

&lt;p&gt;Happy to hear any feedback — whether you're deploying it in-house or just trying it out. It's on GitHub.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/plan-drift" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/plan-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;em&gt;This is a personal OSS project with no warranty. If you hit bugs or have suggestions, GitHub issues are the best way to reach me.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>python</category>
      <category>cli</category>
      <category>analytics</category>
      <category>opensource</category>
    </item>
    <item>
      <title>Your README code examples are silently lying — I built a CLI to detect documentation drift using only AST, no LLM</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Wed, 16 Sep 2026 23:58:15 +0000</pubDate>
      <link>https://dev.to/sunnydachs/your-readme-code-examples-are-silently-lying-i-built-a-cli-to-detect-documentation-drift-using-1blp</link>
      <guid>https://dev.to/sunnydachs/your-readme-code-examples-are-silently-lying-i-built-a-cli-to-detect-documentation-drift-using-1blp</guid>
      <description>&lt;h1&gt;
  
  
  Your README code examples are silently lying — I built a CLI to detect documentation drift using only AST, no LLM
&lt;/h1&gt;

&lt;blockquote&gt;
&lt;p&gt;"Did you know? This README example doesn't work."&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;Have you ever had a junior engineer tell you that? Or worse — have you added a line to your code, forgot the docs, and broke it &lt;em&gt;yourself&lt;/em&gt;?&lt;/p&gt;

&lt;p&gt;This time I want to tell you about &lt;strong&gt;doc-drift&lt;/strong&gt;, a CLI I built that detects this "documentation drift" — the silent gap between code examples in your Markdown and the actual codebase — &lt;strong&gt;without using an LLM at all&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/doc-drift" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/doc-drift&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;p&gt;You point it at a repository. It scans your Markdown files for fenced code blocks, extracts the functions and classes defined there, and checks whether they still match the real codebase.&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;# scan the repo (read-only)&lt;/span&gt;
doc-drift

&lt;span class="c"&gt;# another repo, machine-readable output&lt;/span&gt;
doc-drift /path/to/repo &lt;span class="nt"&gt;--json&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Example output:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;doc-drift — scanned /home/dev/myproject
  markdown files: 42 | python blocks checked: 87
README.md:153  ⚠️ SIGNATURE DRIFT
    block defines (url, retries) but repository defines (url, timeout)
summary: {"signature_drift": 3, "missing": 1, "unparseable": 5} | clean blocks: 41
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It reports three main findings:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;SIGNATURE DRIFT&lt;/strong&gt; — the documented function exists, but its arguments have diverged from the real code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;MISSING&lt;/strong&gt; — the documented function/class no longer exists anywhere in the repo (renamed? deleted?).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;UNPARSEABLE&lt;/strong&gt; — the block isn't valid Python (pseudo-code, placeholders) — informational only.&lt;/li&gt;
&lt;/ul&gt;

&lt;blockquote&gt;
&lt;p&gt;A "function exists, but arguments differ" bug is one that IDEs and static analysis can &lt;strong&gt;never&lt;/strong&gt; catch. Docs aren't executed — so nothing in the toolchain ever checks them. That's the hole.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  Why deliberately &lt;em&gt;not&lt;/em&gt; use an LLM
&lt;/h2&gt;

&lt;p&gt;This was the core design decision. An AI reading the natural-language docs could grasp more "context." But I deliberately built it on &lt;strong&gt;nothing but Python's standard &lt;code&gt;ast&lt;/code&gt; (Abstract Syntax Tree)&lt;/strong&gt;. Three reasons:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Deterministic.&lt;/strong&gt; Same input → same output, every time. I don't want a tool in CI whose verdict flips between runs.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only and safe.&lt;/strong&gt; It never imports or executes your code — it compares at the syntax-tree level. Your files stay untouched.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Zero dependencies.&lt;/strong&gt; Python 3.11+ is enough. Install is &lt;code&gt;pip install git+...&lt;/code&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;blockquote&gt;
&lt;p&gt;This is one answer to the question I keep asking myself when automating: &lt;em&gt;how much should I hand to an AI?&lt;/em&gt; Deterministic work deserves deterministic tools.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  The rule: docs may &lt;em&gt;omit&lt;/em&gt;, but they must never &lt;em&gt;invent&lt;/em&gt;
&lt;/h2&gt;

&lt;p&gt;The detail I obsessed over: code examples in docs are almost always simplified versus the real code. Omitting an argument should be fine. So the rule is:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Omit&lt;/strong&gt; arguments — allowed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Invent&lt;/strong&gt; arguments or functions that don't exist in the code — forbidden.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That single line ("省略は許す、発明は許さない" / &lt;em&gt;omit is fine, inventing is not&lt;/em&gt;) eliminated the vast majority of false positives. Classes work the same way: every documented method must exist on the real class, but unmentioned methods are treated as omitted.&lt;/p&gt;

&lt;h2&gt;
  
  
  Testing against real-world data
&lt;/h2&gt;

&lt;p&gt;Not a toy README — a &lt;strong&gt;real, large open-source repository&lt;/strong&gt;:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Scanned &lt;strong&gt;1,692 Markdown files / 4,451 code blocks&lt;/strong&gt;.&lt;/li&gt;
&lt;li&gt;Found &lt;strong&gt;one genuine drift&lt;/strong&gt;: a function documented with 2 arguments whose implementation had since moved to 1 argument.&lt;/li&gt;
&lt;li&gt;Also surfaced our own over-eager default exclusion that caused false positives — so we fixed the configuration.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The real win wasn't just "it works" — it was confirming in practice that the tool does &lt;em&gt;not&lt;/em&gt; degrade into a noisy false-positive spammer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Honest limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;It can't tell "documentation" from "illustration."&lt;/strong&gt; A hypothetical example in a top-level README (code never meant to exist) reports as &lt;code&gt;MISSING&lt;/code&gt;. It shines in &lt;code&gt;docs/&lt;/code&gt; trees whose examples mirror the codebase.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Python only&lt;/strong&gt; (for now). Other languages are counted but not checked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Name-based comparison.&lt;/strong&gt; Default values and type annotations are ignored — it catches renames, removals, and arity/name changes, not semantic drift.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Wrap-up
&lt;/h2&gt;

&lt;p&gt;Docs-vs-code drift only gets more dangerous as a project grows. And these "quiet but sharp-toothed" problems are exactly where deterministic tools earn their keep.&lt;/p&gt;

&lt;p&gt;This is one in a series I'm building on the same principles (deterministic / read-only / dry-run-first). A sibling, &lt;strong&gt;plan-drift&lt;/strong&gt;, applies the same approach to analytics tracking plans.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://github.com/sunnydachs/doc-drift" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/doc-drift&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Feedback, and especially &lt;em&gt;false-positive reports from your own repos&lt;/em&gt;, are the most useful thing you can give me — drop them in the repo issues.&lt;/p&gt;

</description>
      <category>cli</category>
      <category>documentation</category>
      <category>softwaredevelopment</category>
      <category>tooling</category>
    </item>
    <item>
      <title>Audit your Excel pivot tables before they break — a read-only CLI with zero dependencies</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Sun, 13 Sep 2026 10:20:56 +0000</pubDate>
      <link>https://dev.to/sunnydachs/audit-your-excel-pivot-tables-before-they-break-a-read-only-cli-with-zero-dependencies-2794</link>
      <guid>https://dev.to/sunnydachs/audit-your-excel-pivot-tables-before-they-break-a-read-only-cli-with-zero-dependencies-2794</guid>
      <description>&lt;p&gt;If you work with Excel workbooks that have multiple pivot tables, you know the drill: a refresh breaks, Excel throws "A PivotTable report cannot overlap another PivotTable report", and nobody can say which pivot's configuration caused it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;pivot-diag&lt;/strong&gt; is a CLI that audits the pivot table configurations inside a workbook &lt;em&gt;before&lt;/em&gt; things break. It reads the OOXML structure directly (standard library only — zero dependencies), and reports:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;⚠️ &lt;strong&gt;OVERLAPPING LOCATIONS&lt;/strong&gt; — two pivot table placements intersect on the same worksheet (the zone where Excel raises the "cannot overlap" error)&lt;/li&gt;
&lt;li&gt;⚠️ &lt;strong&gt;OVERLAPPING SOURCES&lt;/strong&gt; — source ranges of multiple pivots intersect on the same sheet (double counting, refresh-order issues)&lt;/li&gt;
&lt;li&gt;· &lt;strong&gt;SHARED SOURCE&lt;/strong&gt; — pivots sharing the exact same source range (informational — fine if intentional)&lt;/li&gt;
&lt;li&gt;❌ &lt;strong&gt;MISSING SOURCE SHEET&lt;/strong&gt; — a cacheSource points to a sheet that no longer exists (renamed or deleted)&lt;/li&gt;
&lt;li&gt;· &lt;strong&gt;informational&lt;/strong&gt; — whole-column refs (&lt;code&gt;A:E&lt;/code&gt;), named-range sources, external sources&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Everything is &lt;strong&gt;deterministic&lt;/strong&gt;: plain &lt;code&gt;zipfile&lt;/code&gt; + &lt;code&gt;ElementTree&lt;/code&gt; parsing of the OOXML parts, no LLM, no network. The tool is &lt;strong&gt;read-only&lt;/strong&gt; — it never modifies your files.&lt;/p&gt;

&lt;h2&gt;
  
  
  How it works
&lt;/h2&gt;

&lt;p&gt;xlsx/xlsm files are OOXML zips. Pivot definitions live in two kinds of parts: &lt;code&gt;xl/pivotCache/pivotCacheDefinitionN.xml&lt;/code&gt; (source ranges) and &lt;code&gt;xl/pivotTables/pivotTableN.xml&lt;/code&gt; (placement and cache references). pivot-diag parses these parts directly with &lt;code&gt;zipfile&lt;/code&gt; + &lt;code&gt;ElementTree&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Deliberately &lt;strong&gt;not via openpyxl&lt;/strong&gt; — pivot table reading is one of the areas where library implementation details leak into your results. Parsing the XML parts directly means the tool doesn't depend on a third-party reader's quirks, and the zero-dependency install is a nice bonus.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Check&lt;/th&gt;
&lt;th&gt;Rule&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;OVERLAPPING LOCATIONS&lt;/td&gt;
&lt;td&gt;two placement refs intersect on the same worksheet&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;OVERLAPPING SOURCES&lt;/td&gt;
&lt;td&gt;source ranges intersect on the same sheet (identical ranges → SHARED SOURCE)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;MISSING SOURCE SHEET&lt;/td&gt;
&lt;td&gt;cacheSource references a sheet that no longer exists&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;UNPARSEABLE REF&lt;/td&gt;
&lt;td&gt;whole-column refs (&lt;code&gt;A:E&lt;/code&gt;) and similar — informational, never guessed&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h2&gt;
  
  
  What I learned from testing it on a real workbook
&lt;/h2&gt;

&lt;p&gt;I built a test workbook generator that assembles OOXML zips directly (no Excel needed) and validated three scenarios:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Clean workbook&lt;/strong&gt; — two pivots with independent source ranges → no findings. Correct no-drift verdict.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Overlapping source ranges&lt;/strong&gt; — two pivots whose source ranges intersect on column C → both &lt;code&gt;OVERLAPPING LOCATIONS&lt;/code&gt; and &lt;code&gt;OVERLAPPING SOURCES&lt;/code&gt; detected.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Broken link&lt;/strong&gt; — renamed the source sheet in the workbook XML → &lt;code&gt;MISSING SOURCE SHEET&lt;/code&gt; detected.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Scenario 3 is the one that matters most in practice: a pivot that references a renamed or deleted sheet is a landmine that only explodes when someone clicks "Refresh". Finding it before that is the whole point.&lt;/p&gt;

&lt;h2&gt;
  
  
  Known limitations
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Read-only.&lt;/strong&gt; No repair, no relocation — the report points at configurations for you to fix.&lt;/li&gt;
&lt;li&gt;Property-level (pivot field) validation is out of scope; the granularity is placement, source range, and cache references.&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;.xls&lt;/code&gt; (legacy format) is not supported — OOXML only.&lt;/li&gt;
&lt;li&gt;Whole-column source refs (&lt;code&gt;A:E&lt;/code&gt;) and named-range sources are reported as informational (not parsed).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Design principle: read-only, always
&lt;/h2&gt;

&lt;p&gt;Diagnostic tools that modify files are a new risk vector. pivot-diag opens the workbook (reads the zip), parses the pivot parts, closes it, and prints a report. That's the entire interaction. If something breaks, your workbook is exactly as it was before.&lt;/p&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Repository: &lt;a href="https://github.com/sunnydachs/pivot-diag" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/pivot-diag&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;This is part of a small family of consistency checkers: &lt;a href="https://github.com/sunnydachs/doc-drift" rel="noopener noreferrer"&gt;doc-drift&lt;/a&gt; (docs vs code) and &lt;a href="https://github.com/sunnydachs/plan-drift" rel="noopener noreferrer"&gt;plan-drift&lt;/a&gt; (tracking plan vs implementation).&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;*As this is an independently developed open-source project, its operation is not guaranteed. Please use it at your own risk. I would appreciate it if you could report any bugs or suggest improvements via issues.&lt;/p&gt;

&lt;h2&gt;
  
  
  License
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://dev.toLICENSE"&gt;MIT&lt;/a&gt;&lt;/p&gt;

</description>
      <category>excel</category>
      <category>python</category>
      <category>opensource</category>
      <category>devtools</category>
    </item>
    <item>
      <title>I automated Dependabot PR cleanup — and drew a hard line on what NOT to automate</title>
      <dc:creator>sunnydachs</dc:creator>
      <pubDate>Sat, 05 Sep 2026 13:30:12 +0000</pubDate>
      <link>https://dev.to/sunnydachs/i-automated-dependabot-pr-cleanup-and-drew-a-hard-line-on-what-not-to-automate-4jm1</link>
      <guid>https://dev.to/sunnydachs/i-automated-dependabot-pr-cleanup-and-drew-a-hard-line-on-what-not-to-automate-4jm1</guid>
      <description>&lt;p&gt;If you use GitHub, you know the rhythm: Dependabot opens a PR, CI runs green, and then a human has to answer the same questions again — &lt;em&gt;is this a patch or a major? did anyone touch source code? does it conflict?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;I got tired of answering those questions by hand, so I built &lt;strong&gt;dep-triage&lt;/strong&gt;: a CLI that sorts open Dependabot PRs into five buckets according to a policy file you commit to your repository.&lt;/p&gt;

&lt;p&gt;🔗 &lt;a href="https://github.com/sunnydachs/dep-triage" rel="noopener noreferrer"&gt;https://github.com/sunnydachs/dep-triage&lt;/a&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;🟢 &lt;strong&gt;auto-merge&lt;/strong&gt; — patch/minor bumps, green CI, dependency-only changes, no conflicts&lt;/li&gt;
&lt;li&gt;🔔 &lt;strong&gt;escalate&lt;/strong&gt; — majors, failed CI, conflicts. Reported, never changed&lt;/li&gt;
&lt;li&gt;🗑️ &lt;strong&gt;close&lt;/strong&gt; — PRs outrun by a newer-version PR for the same package&lt;/li&gt;
&lt;li&gt;💬 &lt;strong&gt;rebase suggestion&lt;/strong&gt; — stale PRs get a &lt;code&gt;@dependabot rebase&lt;/code&gt; comment&lt;/li&gt;
&lt;li&gt;⏭️ &lt;strong&gt;skip&lt;/strong&gt; — CI still running, or the diff touches non-dependency files&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Dry-run is the default. Decisions are fully deterministic — there is no LLM anywhere in the loop. This post is about the line I drew between &lt;em&gt;what to automate&lt;/em&gt; and &lt;em&gt;what never to automate&lt;/em&gt;, and the three traps real-world data exposed.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. Only dependency-only diffs are candidates
&lt;/h2&gt;

&lt;p&gt;"CI is green" does not mean "the change is safe" — CI can only check what it knows about. So the mechanical gate is: &lt;strong&gt;every changed file must be a dependency manifest or lockfile&lt;/strong&gt;. One stray source file and the PR is out of scope, no matter how nice its diff looks.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;scope_check&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;list&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="nb"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dependency_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]}&lt;/span&gt;
    &lt;span class="n"&gt;offending&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="nf"&gt;is_dependency_file&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dependency_only&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;offending&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;offending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;offending&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The check is dumb on purpose — a path list against a known set of manifests and lockfiles. No cleverness, no exceptions to reason about.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. The world changes between judging and acting
&lt;/h2&gt;

&lt;p&gt;Triage says "auto-merge this one". But between that judgment and the API call that enables auto-merge, a new commit can land, or CI can flip red. So &lt;code&gt;--apply&lt;/code&gt; &lt;strong&gt;re-fetches the head SHA, CI state, and changed files immediately before&lt;/strong&gt; enabling auto-merge, and aborts to escalate if anything moved:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;action&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;auto_merge&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;fresh&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&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="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/repos/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/pulls/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;num&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fresh&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;head&lt;/span&gt;&lt;span class="sh"&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;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;facts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;head_sha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# head SHA changed since triage
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
    &lt;span class="n"&gt;ci&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;api&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ci_state&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;repo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fresh&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;head&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sha&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;ci&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ci_green&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="n"&gt;ci&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ci_pending&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="n"&gt;record&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;action&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;   &lt;span class="c1"&gt;# CI state changed before merge
&lt;/span&gt;        &lt;span class="bp"&gt;...&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Classic TOCTOU (time-of-check to time-of-use), but it's the difference between "safe to put in cron" and "safe only while you watch it".&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Majors are never auto-merged. Ever.
&lt;/h2&gt;

&lt;p&gt;Even if your policy listed &lt;code&gt;major&lt;/code&gt; as allowed, the never-list wins. And titles that don't parse are escalated, not guessed:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;bump&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;facts&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;bump&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;bump&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;never_auto_merge_bumps&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
    &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;bump&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; bump is never auto-merged&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;_out&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;escalate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;reasons&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The scariest failure mode of automation is behavior that is "usually smart, occasionally dangerous". The dangerous side is capped by construction, not by config discipline.&lt;/p&gt;

&lt;h2&gt;
  
  
  Real data broke it three times
&lt;/h2&gt;

&lt;p&gt;I ran the tool (dry-run) against a real public repository with 11 open Dependabot PRs. Three problems surfaced that unit tests could never catch:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. GitHub's "no CI" trap.&lt;/strong&gt; A commit with &lt;em&gt;no CI checks at all&lt;/em&gt; comes back from the combined-status endpoint as &lt;code&gt;state=pending&lt;/code&gt; with &lt;code&gt;statuses=0&lt;/code&gt;. So "no CI" and "CI running" are indistinguishable in the raw response — my first run classified all 11 PRs as "CI still running". Fix: an empty pending status means "nothing to wait for", and that fact (&lt;code&gt;ci_none&lt;/code&gt;) is surfaced in the reasons.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. Conventional-commit prefixes.&lt;/strong&gt; Real Dependabot titles look like &lt;code&gt;"chore(deps): bump @nestjs/core from 11.2.1 to 12.0.1"&lt;/code&gt; — prefix and lowercase. My &lt;code&gt;^Bump&lt;/code&gt; regex matched nothing. Every PR came back "unknown level". Fix: accept the prefix, case-insensitively.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. A small propagation miss.&lt;/strong&gt; The &lt;code&gt;ci_none&lt;/code&gt; flag wasn't carried into the decision facts, so the reason line never showed it. Cosmetic, but transparency matters when a bot touches your repo.&lt;/p&gt;

&lt;p&gt;After the fixes, the same 11 PRs triaged like this:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;summary: {"auto_merge": 6, "comment_rebase": 1, "escalate": 4}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The six auto-merge candidates were all patch/minor. Three of the four escalations were &lt;strong&gt;real major upgrades&lt;/strong&gt; — &lt;code&gt;@nestjs/core&lt;/code&gt; 11→12, &lt;code&gt;@types/node&lt;/code&gt; 24→26 — and the never-merge rule caught every one of them. That was the moment I stopped worrying about running this unattended. (The last one was a 14-day-old requirements range bump, routed to a rebase suggestion.)&lt;/p&gt;

&lt;p&gt;Same lesson as always: tests were green; the real world was a different dataset.&lt;/p&gt;

&lt;h2&gt;
  
  
  Using it
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;pip &lt;span class="nb"&gt;install &lt;/span&gt;git+https://github.com/sunnydachs/dep-triage.git

dep-triage &lt;span class="nt"&gt;--repo&lt;/span&gt; owner/name            &lt;span class="c"&gt;# dry-run: print the plan, change nothing&lt;/span&gt;
dep-triage &lt;span class="nt"&gt;--repo&lt;/span&gt; owner/name &lt;span class="nt"&gt;--apply&lt;/span&gt;    &lt;span class="c"&gt;# perform actions (needs write token)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Policy is one TOML file:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight toml"&gt;&lt;code&gt;&lt;span class="py"&gt;auto_merge_bumps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"patch"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"minor"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;   &lt;span class="c"&gt;# levels eligible for auto-merge&lt;/span&gt;
&lt;span class="py"&gt;never_auto_merge_bumps&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;"major"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;      &lt;span class="c"&gt;# always wins over the list above&lt;/span&gt;
&lt;span class="py"&gt;require_ci_green&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;require_dependency_only&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;close_superseded&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;
&lt;span class="py"&gt;rebase_stale_days&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;7&lt;/span&gt;
&lt;span class="py"&gt;merge_method&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"squash"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Auth is a &lt;code&gt;GITHUB_TOKEN&lt;/code&gt; env var; dry-run works with read access only.&lt;/p&gt;

&lt;p&gt;※If Dependabot triage is eating your review time too, give it a spin — and tell me where the line should be drawn differently.&lt;/p&gt;

</description>
      <category>dependabot</category>
      <category>github</category>
      <category>devops</category>
      <category>python</category>
    </item>
  </channel>
</rss>
