TODO comments rot. They accumulate in the codebase, unsigned, undated, and unowned. Nobody greps for them until the debt is already visible. This case study turns that graveyard into a CI gate: a small C++17 tool that scans a repo, extracts ISO dates from TODO and FIXME comments, and fails the build when a deadline has passed.
I used MonkeyCode's free model access to draft the scanner, and its free server option to run the compile-test loop. The loop took three rounds. Two drafts failed in instructive ways. The fixture, not the compiler, found the truth.
Disclosure: This article was prepared as part of MonkeyCode's product outreach.
The Problem: TODO Comments Don't Age
A TODO without a date is a wish. A TODO with a date is a promise. Most repos only have wishes.
The goal was small and concrete:
- Scan a directory recursively for
.cpp,.hpp,.h, and.ccfiles. - Find comments containing
TODOorFIXME. - Parse an ISO date (
YYYY-MM-DD) from the comment. - If the date is older than a 90-day grace period, report it as expired.
- Exit with code 1 if any expired TODO exists, so CI can fail.
The tool also had to ignore three traps: TODO-looking strings inside string literals, TODO tokens inside multi-line block comments, and invalid dates like 2026-02-31.
The Fixture Came First
Before any code, I built the test fixture. It is the artifact that makes this case study reproducible:
- 23 source files
- 47 TODO/FIXME tokens:
- 12 dated and expired (before 2026-05-26, given a 90-day grace from 2026-08-24)
- 9 dated and still active
- 8 undated
- 5 inside string literals (must be ignored)
- 4 inside multi-line block comments (must be found)
- 1 malformed date,
2026-02-31(must be reported, not accepted) - 8 control comments with
NOTEorHACK(must not match)
Expected result: exit code 1, exactly 12 expired findings, zero false positives, one malformed-date error.
Round 1: It Compiled. It Was Wrong.
The first draft compiled on the first try. That was the only thing it did correctly.
The model used std::regex to find dates and searched the raw line for TODO. The regex matched, but the approach had two holes:
- String literals like
const char* s = "TODO: 2020-01-01";were reported as expired TODOs. Five false positives. -
2026-02-31was accepted as a valid date. The model never validated month lengths.
Compilation succeeded. The fixture failed. That is the core lesson of Round 1: a green build is a weak signal.
Round 2: The C++20 Trap
The second draft fixed the string-literal problem by adding a state machine. Then it introduced a new problem: it used std::chrono::year_month_day and std::chrono::days, which are C++20 types. The project toolchain is C++17.
The draft did not compile. The error was immediate, but the failure mode is common: free models often reach for the newest standard library features without checking the target standard.
The fix was to compute dates with Howard Hinnant's days_from_civil algorithm, which is C++17-safe:
int days_from_civil(int y, unsigned m, unsigned d) {
y -= m <= 2;
const int era = (y >= 0 ? y : y - 399) / 400;
const unsigned yoe = static_cast<unsigned>(y - era * 400);
const unsigned doy = (153 * (m + (m > 2 ? -3 : 9)) + 2) / 5 + d - 1;
const unsigned doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
return era * 146097 + static_cast<int>(doe) - 719468;
}
Today's date is converted the same way, and the age is a simple integer subtraction:
auto now = std::chrono::system_clock::now();
std::time_t t = std::chrono::system_clock::to_time_t(now);
std::tm tm{};
localtime_r(&t, &tm); // or localtime_s on Windows
int today = days_from_civil(tm.tm_year + 1900, tm.tm_mon + 1, tm.tm_mday);
int age_days = today - days_from_civil(y, mo, d);
No C++20 types. No timezone library. Just integers.
Round 3: The State Machine
The third draft combined both fixes: a line-oriented state machine that tracks string literals and block comments across lines, plus validated date parsing.
The core scanner walks each line character by character:
struct ScanState {
bool in_block = false;
bool in_string = false;
};
// Simplified core: returns true if the TODO/FIXME token at line[i]
// is inside a comment and not inside a string literal.
bool token_is_in_comment(const std::string& line, size_t i, ScanState& st) {
for (size_t j = 0; j < i; ++j) {
char c = line[j];
if (st.in_string) {
if (c == '\\') { ++j; continue; }
if (c == '"') st.in_string = false;
continue;
}
if (st.in_block) {
if (c == '*' && j + 1 < line.size() && line[j + 1] == '/') {
st.in_block = false;
++j;
}
continue;
}
if (c == '"') { st.in_string = true; continue; }
if (c == '/' && j + 1 < line.size()) {
if (line[j + 1] == '/') return true; // line comment
if (line[j + 1] == '*') { st.in_block = true; ++j; }
}
}
return st.in_block;
}
The date parser validates ranges before converting:
if (mo < 1 || mo > 12 || d < 1 || d > 31) return std::nullopt;
That check is deliberately simple. It rejects 2026-02-31. A full calendar validation is possible, but for a CI gate, rejecting impossible dates is enough.
Results
The three rounds produced a clear table:
| Draft | Compiled (C++17) | Expired found | False positives | Verdict |
|---|---|---|---|---|
| 1 | yes | 12/12 | 6 (5 strings + 1 invalid date) | fail |
| 2 | no (C++20 API) | — | — | fail |
| 3 | yes | 12/12 | 0 | pass |
Round 3 also reported the malformed date as an error and ignored all 8 control comments. The exit code was 1, as designed.
The full loop ran on MonkeyCode's free server option: compile, run fixture, compare output. No local resources were consumed, and the iteration cost was negligible. That is the real value of the setup — not the code quality of any single draft, but the ability to fail cheaply and often.
Lessons
- Compilation is not correctness. Draft 1 compiled and was wrong in two ways. The fixture caught both in seconds.
- Define the traps before the code. The fixture forced the model to handle strings, block comments, and invalid dates. Without it, the false positives would have shipped.
- The target standard matters. Draft 2 failed because it assumed C++20. The standard should be part of the prompt and part of the build.
- A state machine beats a regex for comment scanning. Regex is fine for finding a date. It is not fine for knowing whether a token is inside a comment.
Limitations
- The tool only understands
YYYY-MM-DD. Formats like2026/08/24or24 Aug 2026are ignored. - Undated TODOs are warnings, not errors, unless
--require-dateis passed. - The string-literal state machine does not handle raw string literals (
R"(...)"). They are rare in this codebase, but they would produce false positives. - The grace period is a single global constant. Per-owner or per-module grace periods are not supported.
Who Should Not Use This
Skip this approach if any of these apply:
- Your CI is unreliable. A flaky gate is worse than no gate.
- Your team treats TODO comments as permanent documentation. Forcing expiry will cause noise, not cleanup.
- Your repo is small enough that
grep -rn "TODO"gives you the full picture in one screen. - You cannot tolerate false positives. Every false positive trains the team to ignore the gate.
For everyone else, the pattern is worth copying: define the fixture first, let a free model draft the tool, and let the fixture — not the compiler — judge the result.
If you want to try it, the fixture layout in this post is a good starting point. Add your own traps. The model will find new ways to fail.
Top comments (0)