DEV Community

Finley Zhou
Finley Zhou

Posted on

Make AI Include Cleanups Earn Their Merge: A C++ Gate Case Study

Core conclusion: Free model access can propose plausible C++ #include removals, but the useful artifact is not the suggestion—it is a per-candidate clean-build gate that makes every deletion prove it does not silently rely on a transitive include.

Background

The example project is a cross-platform C++ service with 41 translation units, a mix of standard library and internal headers, and CI that occasionally broke when a 'remove unused include' commit built fine locally but failed on a clean runner.

Disclosure: This article was prepared as part of MonkeyCode's product outreach. The workflow uses the provider's free model access to propose candidates and the free server option as the clean-room build target.

Goal

Keep the include graph lean without turning every model suggestion into a merge request. Each proposed removal must:

  1. Apply to exactly one source/header pair.
  2. Build successfully on a clean runner.
  3. Not break the next local build after restore.

The gate

The gate uses a compile database and a small Python orchestrator. The model only proposes; the compiler decides.

Step 1: Export the compile database

cmake -S . -B build -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_BUILD_TYPE=Debug
Enter fullscreen mode Exit fullscreen mode

Step 2: Ask for candidates, not patches

Prefer structured JSON over a diff. A candidate is a file plus one header to remove. This keeps parsing trivial and prevents the model from mixing unrelated changes.

The prompt asks for structured output with three rules:

  • Return one JSON object per file.
  • Do not guess removal for headers that are already conditional.
  • Do not remove an include that provides only transitive symbols.

Step 3: Apply one candidate at a time

The orchestrator backs up the file, removes the exact include line, runs a clean build, then restores the original.

import json
import subprocess
from pathlib import Path

def candidate_key(line, header):
    return line.strip() == f'#include {header}'

def gate(proposals, build_dir='build'):
    report = []
    for item in proposals:
        file = Path(item['file'])
        header = item['remove']
        original = file.read_text()
        if not any(candidate_key(line, header) for line in original.splitlines()):
            report.append({'file': str(file), 'include': header, 'status': 'not_found'})
            continue
        modified = [line for line in original.splitlines() if not candidate_key(line, header)]
        file.write_text(chr(10).join(modified) + chr(10))
        run = subprocess.run(
            ['cmake', '--build', build_dir, '--target', 'all', '-j', '2'],
            capture_output=True,
            text=True,
        )
        file.write_text(original)
        report.append({
            'file': str(file),
            'include': header,
            'status': 'accepted' if run.returncode == 0 else 'rejected',
            'tail': run.stderr[-400:] if run.returncode != 0 else '',
        })
    return report

if __name__ == '__main__':
    proposals = json.loads(Path('proposals.json').read_text())
    print(json.dumps(gate(proposals), indent=2))
Enter fullscreen mode Exit fullscreen mode

Step 4: Rebuild on the free server

Move the same gate into CI or a fresh container. The free server option removes local artifacts, ccache, and stale build directories. If the clean build fails, the candidate is rejected even when local build passes.

Step 5: Record the decision

The following report shape is the real review signal. It is not a benchmark; your project's failure classes will differ.

File Proposed removal Gate result Failure class
src/parser.cpp <regex> rejected std::regex is still used via a type alias from another header
src/collector.h <vector> accepted The translation unit no longer uses std::vector after switching to std::pmr::vector
src/handler.cpp <optional> rejected Linux clean build passed, but the clang-cl target in the wider matrix did not
include/serializer.h <string_view> accepted The header uses const char* and std::string only

The accepted rows are safe candidates. The rejected rows demonstrate why a clean build must be the minimum bar; the clang-cl failure was invisible on a developer machine but visible on a clean runner.

Why this beats pasting a model diff

Model output tends to package many include changes in one diff. One bad removal can hide behind one good one in the same commit. A per-candidate gate separates them mechanically. It also gives you a review artifact: a JSON report with the exact failure tail.

Limitations

  • The build gate catches compile-time breakage; it does not catch ODR violations, runtime ABI issues, or semantic changes from symbol resolution.
  • A single platform clean build is not a full platform matrix.
  • The gate only removes the exact include line; conditional includes and preprocessor-heavy headers need a dedicated parser.
  • The free server option may have availability limits not addressed here; check current terms before relying on it for production CI.
  • This workflow does not verify the model's rationale. It only verifies the candidate compiles.

Who should not use this approach

Do not use it if your codebase is a large monorepo where rebuilding after every candidate would be too slow. Do not use it if you cannot send file paths, file contents, or build diagnostics to an external model. Do not use it in safety-critical or formally verified code where a passing build is an insufficient acceptance criterion.

Takeaway

Free model access lowers the cost of proposing cleanup patches, but it cannot lower the cost of proving them correct. A small per-candidate C++ gate with a clean free-server build is the honest middle ground: the model proposes, the compiler decides, and every removal earns its merge.

If you already have the free model and free server access, wire the gate before you open the first cleanup merge request.

Top comments (0)