<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[ThunderKhan]]></title><description><![CDATA[ThunderKhan]]></description><link>https://thunder-khan.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6a9404383a4c64e912294e08/28b2f659-fc79-4888-8644-cd8e5d3a16cb.jpg</url><title>ThunderKhan</title><link>https://thunder-khan.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Tue, 01 Sep 2026 21:02:10 GMT</lastBuildDate><atom:link href="https://thunder-khan.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Built C++ Test Impact Analysis With Zero Runtime Dependencies]]></title><description><![CDATA[I entered a zero-dependency hackathon thinking the hard part would be selecting tests.
It wasn't.
The hard part was deciding when I had enough evidence to safely not run one.
Most C++ projects have a ]]></description><link>https://thunder-khan.hashnode.dev/i-built-cpp-test-impact-analysis-with-zero-runtime-dependencies</link><guid isPermaLink="true">https://thunder-khan.hashnode.dev/i-built-cpp-test-impact-analysis-with-zero-runtime-dependencies</guid><category><![CDATA[C++]]></category><category><![CDATA[hackathon]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Open Source Community]]></category><category><![CDATA[Developer Tools]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Systems Programming]]></category><category><![CDATA[software development]]></category><dc:creator><![CDATA[Ayan Khan]]></dc:creator><pubDate>Sun, 30 Aug 2026 10:39:20 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6a9404383a4c64e912294e08/601df087-5efb-45ff-9476-98165d5e54be.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>I entered a zero-dependency hackathon thinking the hard part would be selecting tests.</p>
<p>It wasn't.</p>
<p>The hard part was deciding when I had enough evidence to safely <em>not</em> run one.</p>
<p>Most C++ projects have a simple answer when code changes: run the test suite. That is safe, but it can get expensive fast. Change one header in a large project and there is a good chance most tests have nothing to do with it.</p>
<p>So I spent the Zero Dependency Hackathon building <strong>diff2test</strong>, a C++20 CLI that tries to answer a narrower question:</p>
<blockquote>
<p>Given these changed files, which CTest tests can I justify running?</p>
</blockquote>
<p>The word <em>justify</em> ended up mattering more than I expected.</p>
<p><code>diff2test</code> reads changed paths and metadata that a CMake/CTest build has already produced. It reconstructs the relationship between files, translation units, targets, executables, and tests. If all the evidence checks out, it emits the affected subset.</p>
<p>If something important is missing or suspicious, the optimization disappears.</p>
<p>That decision shaped almost everything that followed.</p>
<hr />
<h2>I already had the graph. It was just scattered everywhere.</h2>
<p>I did not want to predict test impact from filenames.</p>
<p>No <code>parser.cpp</code> probably means <code>ParserTest</code>. No directory heuristics. No fuzzy matching. No history model.</p>
<p>A normal C++ build already knows much more useful information.</p>
<p>GCC and Clang can emit Make-style <code>.d</code> files containing the prerequisites of each compilation.</p>
<p>CMake's File API can describe targets, their sources, their dependencies, and the artifacts they produce.</p>
<p>CTest can export its registered tests and their commands as JSON.</p>
<p>Put those pieces together and the path I wanted looked roughly like this:</p>
<pre><code class="language-text">changed path
    ↓
compiler .d file
    ↓
translation unit
    ↓
CMake target
    ↓
targets that depend on it
    ↓
executable artifact
    ↓
CTest test
</code></pre>
<img src="https://raw.githubusercontent.com/ThunderKhan/diff2test/main/assets/diff2test-workflow.png" alt="How diff2test maps changed files to affected tests" style="display:block;margin:0 auto" />

<p>That looked almost suspiciously convenient.</p>
<p>Then I checked the hackathon rules.</p>
<hr />
<h2>The organizer email changed the project</h2>
<p>My first design would have been much easier.</p>
<p><code>diff2test</code> could run <code>git diff</code>, ask CMake for metadata, invoke CTest, maybe use a helper command to discover dependency files, and combine the results.</p>
<p>There was one problem: this was the <strong>Zero Dependency Hackathon</strong>.</p>
<p>I emailed the organizers and asked specifically whether launching Git, CMake, or CTest from the program would count as depending on separately installed software.</p>
<p>Their answer was yes.</p>
<p>If my executable shelled out to <code>git</code>, then Git was part of its runtime dependency story. Same for CMake and CTest.</p>
<p>They did give me an important escape route: parsing files those tools had <strong>already generated</strong> was allowed, provided I disclosed that boundary and handled missing metadata gracefully.</p>
<p>So I changed the architecture.</p>
<p>The program would not produce its own evidence. It would consume evidence.</p>
<p>A workflow can still do this:</p>
<pre><code class="language-bash">git diff --name-only HEAD~1 | ./build/diff2test analyze .
</code></pre>
<p>but <code>diff2test</code> never launches Git. The shell does that. Git writes paths to stdout and <code>diff2test</code> reads newline-delimited paths from stdin.</p>
<p>The same input could just as easily come from:</p>
<pre><code class="language-bash">printf 'include/alpha.hpp\n' | ./build/diff2test analyze .
</code></pre>
<p>or a file:</p>
<pre><code class="language-bash">./build/diff2test analyze . --changed-files changed.txt
</code></pre>
<p>CMake, CTest, and the compiler follow the same boundary. They can generate metadata before analysis. The running <code>diff2test</code> process only reads files and stdin.</p>
<p>This distinction became important because calling the project "zero dependency" without explaining where the boundary sits would be misleading. The supported workflow absolutely uses CMake metadata. CMake just is not bundled, linked, or executed by the shipped program.</p>
<hr />
<h2>My first real fixture broke two assumptions quickly</h2>
<p>I built a tiny CMake/CTest project early instead of spending the whole hackathon implementing against imaginary metadata.</p>
<p>Good decision.</p>
<p>The first thing I had to correct was CMake target traversal.</p>
<p>CMake naturally tells me that something like a test executable depends on a library:</p>
<pre><code class="language-text">alpha_test
    ↓
alpha
</code></pre>
<p>Impact analysis asks the opposite question.</p>
<p>If <code>alpha</code> was affected, I need to find everything <em>downstream</em> that depends on it.</p>
<p>So I built reverse adjacency:</p>
<pre><code class="language-text">alpha
    ↓
alpha_test
</code></pre>
<p>and propagate impact outward from the target owning the changed translation unit.</p>
<p>That part was straightforward once I saw the real data.</p>
<p>The <code>.d</code> files were more annoying.</p>
<p>My original plan was to recursively scan the build directory and collect anything ending in <code>.d</code>.</p>
<p>Then I found <code>link.d</code>.</p>
<p>Right extension. Wrong meaning.</p>
<p>That was enough to kill recursive discovery.</p>
<p>The final MVP takes an explicit dependency-file list through <code>--dep-list</code>. That list is created outside the process, and <code>diff2test</code> still validates every entry against the CMake targets and compiled sources it already knows about.</p>
<p>It is slightly more manual.</p>
<p>I trust it more.</p>
<hr />
<h2>Then I had to decide what "fallback" actually means</h2>
<p>At this point I had the basic graph working, and my mental model had two possible results:</p>
<pre><code class="language-text">affected subset
or
full suite
</code></pre>
<p>Then I started deleting inputs.</p>
<p>Suppose the CTest catalogue is valid and contains:</p>
<pre><code class="language-text">AlphaTest
BetaTest
CoreTest
</code></pre>
<p>but one required dependency file disappears.</p>
<p>I can no longer prove that a narrow result is safe, but I still know exactly what the full test catalogue contains.</p>
<p>So the correct result is:</p>
<pre><code class="language-text">AlphaTest
BetaTest
CoreTest
</code></pre>
<p>That became:</p>
<pre><code class="language-text">FULL_SUITE_SELECTED
exit 10
</code></pre>
<p>Then I deleted the CTest catalogue itself.</p>
<p>Now there was a more basic problem.</p>
<p>I no longer knew what "the full suite" meant.</p>
<p>Printing three remembered test names would be fabricated output. An empty test list could be misinterpreted as "nothing needs testing."</p>
<p>That produced a separate state:</p>
<pre><code class="language-text">FULL_SUITE_REQUIRED
exit 11
</code></pre>
<p>It means the program cannot safely enumerate the suite and the caller should run its normal full-test procedure.</p>
<p>The successful narrow case is:</p>
<pre><code class="language-text">SUBSET_SELECTED
exit 0
</code></pre>
<p>So the three central outcomes became:</p>
<pre><code class="language-text">complete supported evidence
    → SUBSET_SELECTED
 
trusted test catalogue, unsafe impact evidence
    → FULL_SUITE_SELECTED
 
test catalogue itself cannot be trusted
    → FULL_SUITE_REQUIRED
</code></pre>
<img src="https://raw.githubusercontent.com/ThunderKhan/diff2test/main/assets/diff2test-safety.png" alt="diff2test conservative safety model" style="display:block;margin:0 auto" />

<p>I made both fallback conditions non-zero on purpose. If this is used in CI, I want degraded analysis to be visible.</p>
<p>That little experiment with deleting <code>ctest-info.json</code> changed the safety model more than any graph algorithm did.</p>
<hr />
<h2>What narrowing looks like when the evidence is good</h2>
<p>The repository contains a controlled fixture with three tests.</p>
<p>If I change a header used only by Alpha:</p>
<pre><code class="language-bash">printf 'include/alpha.hpp\n' \
  | ./build/diff2test analyze fixture --format names
</code></pre>
<p>the output is:</p>
<pre><code class="language-text">AlphaTest
</code></pre>
<p>There is also a shared header used by Alpha and Beta:</p>
<pre><code class="language-bash">printf 'include/features_shared.hpp\n' \
  | ./build/diff2test analyze fixture --format names
</code></pre>
<p>which produces:</p>
<pre><code class="language-text">AlphaTest
BetaTest
</code></pre>
<p><code>CoreTest</code> stays out.</p>
<p>Remove required dependency evidence and all three known tests come back with exit <code>10</code>.</p>
<p>Remove the CTest catalogue and no test names are invented at all. The program exits <code>11</code>.</p>
<p>The changed path can stay identical through all of those runs. What changes is how much of the evidence graph I am willing to trust.</p>
<hr />
<h2>Zero dependencies meant I owned every boring parser</h2>
<p>The runtime implementation is a single C++20 source file.</p>
<p>That was one of the hackathon bonus constraints, but the more interesting consequence was that I could not quietly pull in the libraries I would normally use for the tedious parts.</p>
<p>JSON was the obvious one.</p>
<p>CMake File API and CTest both give me JSON, so I needed a parser.</p>
<p>At first this sounds like objects, arrays, strings, numbers, and a recursive-descent function or two.</p>
<p>Then malformed input enters the picture.</p>
<p>The parser ended up handling:</p>
<ul>
<li><p>strict JSON number grammar</p>
</li>
<li><p>UTF-8 validation</p>
</li>
<li><p>string escapes</p>
</li>
<li><p>Unicode escapes and surrogate pairs</p>
</li>
<li><p>duplicate object keys</p>
</li>
<li><p>positional error information</p>
</li>
<li><p>nesting limits</p>
</li>
<li><p>maximum input size</p>
</li>
<li><p>maximum string size I also wrote the Make-style dependency parser.</p>
</li>
</ul>
<p>Real <code>.d</code> files can contain line continuations, escaped spaces, escaped characters, comments, CRLF input, repeated prerequisites, and malformed rules. One stress test feeds the parser a rule containing <strong>10,000 prerequisites</strong>.</p>
<p>This is probably the part of the hackathon that changed my view of dependencies the most.</p>
<p>In a normal project I would use a mature JSON library. Happily.</p>
<p>Writing my own here was useful because it exposed how much correctness work disappears behind a small include statement.</p>
<p>The standard library gave me the pieces. It did not give me JSON, Make dependency syntax, or the trust policy I needed around either of them.</p>
<hr />
<h2><code>std::filesystem</code> did not solve path safety for me</h2>
<p>Path handling looked less interesting than parsing.</p>
<p>It consumed plenty of time anyway.</p>
<p>A changed file can be deleted, so I cannot assume every path exists on disk and blindly canonicalize it.</p>
<p>I also needed to handle things such as:</p>
<pre><code class="language-text">/project/foo
/project/foobar
</code></pre>
<p>which share a string prefix without one being contained in the other.</p>
<p>There are also <code>..</code> escapes, project-root boundaries, build-root boundaries, relative metadata paths, and artifact paths coming from CMake.</p>
<p>I ended up using lexical normalization plus explicit containment checks instead of treating filesystem canonicalization as the answer to every path question.</p>
<p><code>std::filesystem</code> was excellent machinery.</p>
<p>The policy was still mine.</p>
<hr />
<h2>The same source file can mean two different compilations</h2>
<p>Another issue showed up once I started thinking about completeness.</p>
<p>Imagine <code>foo.cpp</code> is compiled into two CMake targets.</p>
<p>Those compilations may have different definitions or include paths.</p>
<p>If I see one dependency file for:</p>
<pre><code class="language-text">foo.cpp
</code></pre>
<p>I cannot mark the source globally "covered."</p>
<p>That dependency information belongs to one compilation in one target.</p>
<p>So dependency completeness is tracked per:</p>
<pre><code class="language-text">(CMake target, compiled source)
</code></pre>
<p>rather than only by source path.</p>
<p>It sounds like a small bookkeeping choice. Without it, one valid <code>.d</code> file could accidentally make another compilation of the same file look covered.</p>
<p>I would rather widen than make that assumption.</p>
<hr />
<h2>CTest executable matching also needed a stricter rule</h2>
<p>CTest tells me what command a registered test runs.</p>
<p>CMake tells me which executable artifacts targets produce.</p>
<p>The tempting shortcut is to compare executable basenames.</p>
<p>I decided against that.</p>
<p>Two directories can contain executables with the same name. Wrapper commands complicate the relationship further. Multi-configuration builds add another source of ambiguity.</p>
<p>For the supported workflow, a CTest command has to map to exactly one normalized CMake executable artifact.</p>
<p>If it does not, subset selection stops.</p>
<p>That choice narrowed the projects I could support during the hackathon, but it kept the mapping explainable.</p>
<hr />
<h2>Stale evidence was worse than missing evidence</h2>
<p>Missing metadata is easy to reason about.</p>
<p>You know it is gone.</p>
<p>Stale metadata is more dangerous because it still looks valid.</p>
<p>A <code>.d</code> file may exist while describing an older compilation. A header might have changed afterward.</p>
<p>For project-local prerequisites, <code>diff2test</code> performs a timestamp check. If a prerequisite is newer than the dependency file that claims to describe it, narrow selection is disabled.</p>
<p>If a timestamp that is required for the check cannot be read safely, the result widens too.</p>
<p>I am deliberately careful about what this proves.</p>
<p>A passing timestamp check does not establish that the metadata cryptographically matches the current source tree. It means I found <strong>no detectable staleness under that policy</strong>.</p>
<p>There are other inputs that I simply refuse to model narrowly.</p>
<p>If <code>CMakeLists.txt</code> or another <code>.cmake</code> file changes, that can alter source membership, target relationships, definitions, generated files, or test registration. Predicting all of that would mean interpreting CMake itself.</p>
<p>So build-configuration changes fall back.</p>
<p>Unknown changed paths do too. A file outside the dependency graph might be irrelevant, or it might be a generated input, script, resource, or something else the current model cannot see.</p>
<p>I do not classify "unknown" as "unaffected."</p>
<hr />
<h2>Explanations came almost for free</h2>
<p>Once I was storing the graph and predecessor relationships, it became possible to show why a test had been selected.</p>
<p>For example:</p>
<pre><code class="language-text">changed path: include/alpha.hpp
dependency file: CMakeFiles/alpha.dir/src/alpha.cpp.o.d
translation unit: src/alpha.cpp
owning target: alpha
dependent target: alpha_test
registered test: AlphaTest
</code></pre>
<p>That turned into <code>--explain</code>.</p>
<p>I like this more than I expected.</p>
<p>If a CI optimization decides that hundreds of tests can be skipped, I want some way to inspect the chain that led to the tests it kept.</p>
<p>It also made debugging much easier during the hackathon. A wrong final set only tells you that something went wrong. A wrong evidence chain often tells you where.</p>
<hr />
<h2>Testing a program whose job is to skip tests felt slightly recursive</h2>
<p>By the end, the repository had seven dependency-free C++ test executables covering the JSON parser, <code>.d</code> parser, path handling, CTest metadata, CMake metadata, impact analysis, and additional hardening.</p>
<p>One synthetic target graph deliberately contains a chain, a diamond, a cycle, and an unrelated branch at the same time.</p>
<p>That caught the properties I cared about:</p>
<ul>
<li><p>transitive reverse traversal</p>
</li>
<li><p>no duplicate propagation through a diamond</p>
</li>
<li><p>cycle termination</p>
</li>
<li><p>unrelated tests remaining unrelated I also wanted real generated metadata in CI, not only JSON fixtures I had written myself.</p>
</li>
</ul>
<p>The CI workflow creates a CMake File API query, configures and builds the controlled fixture, collects real compiler <code>.o.d</code> files, exports real CTest JSON, and analyzes those artifacts with <code>diff2test</code>.</p>
<p>The narrow Alpha case, the Alpha/Beta shared-header case, missing dependency evidence, and missing CTest catalogue are all exercised there.</p>
<p>Some failures were less interesting.</p>
<p>At one point a helper function in a test collided with <code>std::quoted</code>.</p>
<p>I renamed it.</p>
<p>That was the fix.</p>
<hr />
<h2>I also tried to attack the zero-dependency claim itself</h2>
<p>"Zero runtime dependencies" is easy to put in a README.</p>
<p>I wanted the repository to make the claim inspectable.</p>
<p>CI scans the runtime source for process-spawning APIs.</p>
<p>The Release executable is inspected with <code>ldd</code>. On the Linux CI runner it showed the normal system/toolchain runtime entries:</p>
<pre><code class="language-text">linux-vdso.so.1
libstdc++.so.6
libgcc_s.so.1
libc.so.6
libm.so.6
/lib64/ld-linux-x86-64.so.2
</code></pre>
<p>There is no CMake application library or third-party project library in that list.</p>
<p>A separate development CI job runs the test executables under AddressSanitizer and UndefinedBehaviorSanitizer. Those are verification tools used while building and testing the project; they are not dependencies of the normal shipped executable.</p>
<p>I also tested determinism.</p>
<p>Reversing CMake references, reversing the explicit dependency list, changing CTest catalogue order, and duplicating changed paths should not change user-visible output.</p>
<p>CI captures real CLI stdout and stderr, compares reordered-evidence runs byte-for-byte, and repeats the same real-fixture analysis 20 times.</p>
<p>That may sound excessive for a hackathon project.</p>
<p>The program decides what <em>not</em> to test. I was comfortable being slightly paranoid.</p>
<hr />
<h2>Reproducible builds</h2>
<p>The hackathon had a reproducible-build bonus.</p>
<p>I asked the organizers what they considered reproducible in this context. The target was two independent builds using the same environment and toolchain producing identical output, rather than pretending GCC, Clang, MSVC, Linux, and Windows should all produce the same bytes.</p>
<p>CI performs two clean Release builds on the same runner.</p>
<p>The binaries were byte-identical and both produced:</p>
<pre><code class="language-text">162a6bbf52034f0c468ab2c7c82853a449590530768e9ed6ddd82f1b7aabc903
</code></pre>
<p>That is the scope of the claim.</p>
<p>Nothing broader.</p>
<hr />
<h2>What did this make unnecessary?</h2>
<p>The hackathon also had a "Package Killer" bonus: identify an installable package or tool whose role your standard-library implementation can remove.</p>
<p>I compared <code>diff2test</code> with <strong>RTS++ / Ekstazi++</strong>, a C++ regression-test-selection system whose broader approach involves infrastructure including LLVM instrumentation and its own RTS components.</p>
<p>I want to keep this claim narrow because the projects do not have identical feature sets.</p>
<p>For the specific CMake/CTest workflow that <code>diff2test</code> supports, I can perform useful conservative test-impact analysis without adding a dedicated RTS runtime stack.</p>
<p>That works because I am reusing evidence that the existing compiler, CMake, and CTest workflow already generated.</p>
<p>No compiler plugin.</p>
<p>No runtime agent.</p>
<p>No historical coverage database.</p>
<p>No daemon.</p>
<p>No network service.</p>
<p>For a broader production RTS system, some of those techniques may be exactly what you want. For this constrained workflow, I wanted to see how far the existing metadata could take me.</p>
<p>Quite far, as it turned out.</p>
<hr />
<h2>A small performance note</h2>
<p>I benchmarked the controlled fixture with 20 warmups and 200 measured full-process invocations.</p>
<p>The result was approximately:</p>
<pre><code class="language-text">median: 2.106 ms
p95:    2.209 ms
</code></pre>
<p>The fixture is tiny, so I am not using that number as evidence that <code>diff2test</code> has solved monorepo-scale performance.</p>
<p>I did not have a verified 100,000-node or million-node benchmark during the hackathon, so I did not invent one.</p>
<p>The useful observation is simply that analysis itself is lightweight once the metadata already exists.</p>
<hr />
<h2>What I would change next</h2>
<p>The most obvious rough edge is <code>--dep-list</code>.</p>
<p>Making the caller explicitly provide the dependency files was the safe choice after the <code>link.d</code> discovery, but it makes setup more manual than I would ultimately like.</p>
<p>I would not solve that by going back to recursive extension matching.</p>
<p>I would add proper evidence adapters for additional build shapes and generators.</p>
<p>Ninja should understand Ninja's dependency representation.</p>
<p>MSVC support should understand MSVC's dependency/path semantics.</p>
<p>Other CMake generators should get their own tested mapping rules.</p>
<p>That is slower than adding heuristics, but it preserves the property I care about.</p>
<p>I would also happily delete my JSON parser in a normal production version and use a mature library.</p>
<p>The hackathon was useful precisely because I had to discover what that dependency normally buys me.</p>
<hr />
<h2>Where it ended</h2>
<p><code>diff2test</code> finished the hackathon as one C++20 runtime source file with no third-party runtime code, no runtime subprocess execution, no network requirement, and a deliberately narrow CMake/CTest test-impact model.</p>
<p>The repository verifies the implementation with native C++ tests, real generated metadata, sanitizer runs, deterministic-output checks, dependency inspection, and byte-identical same-runner Release builds.</p>
<p>There are plenty of things it does not support yet: MSVC dependency formats, Ninja's dependency database, arbitrary CMake generator layouts, wrapper/interpreter-style CTest commands, generated custom-command relationships, coverage-guided selection, and historical test mappings.</p>
<p>Those boundaries are written down because guessing around them would defeat the point.</p>
<p>I started the weekend focused on the graph that selects affected tests.</p>
<p>I finished it thinking much more about the checks surrounding that graph.</p>
<p>A fast path is easy to write.</p>
<p>The interesting engineering starts when you have to decide whether you are actually allowed to take it.</p>
<p>For <code>diff2test</code>, that became the lesson I care about most:</p>
<p><strong>safe optimization is mostly precondition engineering.</strong></p>
<hr />
<h2>Links</h2>
<p><strong>Repository:</strong> <a href="https://github.com/ThunderKhan/diff2test">https://github.com/ThunderKhan/diff2test</a></p>
<p><strong>Release:</strong> <a href="https://github.com/ThunderKhan/diff2test/releases/tag/v0.1.1">https://github.com/ThunderKhan/diff2test/releases/tag/v0.1.1</a></p>
<p><strong>Demo:</strong> <a href="https://www.youtube.com/watch?v=TQp6%5C_BOJHbw">https://www.youtube.com/watch?v=TQp6\_BOJHbw</a></p>
<p>Built during the <strong>Zero Dependency Hackathon 2026</strong> by Hackathon Raptors.</p>
]]></content:encoded></item></channel></rss>