Testing and Reviewing AI-Generated Code in CI
A continuous integration pipeline does not care who wrote a change. It runs the checks it was given and reports pass or fail. That neutrality is useful, and it hides a problem: a change produced by a code-generating model arrives with a particular set of risks, and a pipeline built for human-authored changes may not test for them. The code looks plausible. The tests that came with it may share its mistakes. It may import a package that nobody on the team has reviewed, or one that does not exist. And the volume of changes can outgrow the attention that reviewers have. This article builds the checks that address those risks: behavioral tests that do not depend on the generator, code and dependency review, security checks, reproducible failure reports, and explicit boundaries for what a human must approve.
The article assumes general computer science and programming knowledge, including reading Python and unit tests. It builds on Integrating AI Coding Tools with Enterprise Repositories, which covered repository access, tool configuration, context boundaries, data retention, and approved environments with audit. The idea carried over from it is that the tool runs with limited authority and its sessions leave evidence. This article starts where that one ends: a change has been proposed, and the pipeline must decide whether to trust it. Everything needed to follow the verification design is explained below.
A note on method. The code in this article was written for it and uses only the Python standard library (Python 3.12 for the outputs shown). Each output block is the actual output of the listing next to it. Third-party tools such as Hypothesis, Bandit, or a mutation-testing package were not installed or run; where the article describes them, it relies on their public documentation, and the behavior described is not something observed here. The function being tested is a hypothetical example written to resemble plausible generated code, and it does not come from any real tool or project.
Why generated code needs verification that is independent of the generator
Two published studies show kinds of problem that are worth designing against. Both describe particular tools and conditions, and neither tells you the rate for the tool you use today.
- Pearce and colleagues studied GitHub Copilot’s code suggestions in 2021 using 89 scenarios based on high-risk entries in MITRE’s weakness list. The scenarios produced 1,689 programs, and the authors found about 40 percent of them vulnerable. The result is specific to that tool version, those scenarios, and their method for judging vulnerability.
- Spracklen and colleagues analyzed 576,000 code samples from 16 models and reported that package hallucinations, meaning generated code that refers to packages that do not exist, occurred at an average of at least 5.2 percent for commercial models and 21.7 percent for open-source models, with 205,474 unique hallucinated package names. The authors describe this as a novel form of package confusion attack, because an attacker could register a name that models tend to invent.
The practical conclusion is not a number. It is that generated code can fail in two ways that a reviewer skimming the diff may miss: insecure patterns that look idiomatic, and references to dependencies that the reviewer has no reason to doubt. A design principle follows. Evidence that a change is correct should come from something that did not share the generator’s assumptions. A test oracle is the basis for deciding whether behavior is correct, and computing the expected answer with the code under test reproduces its mistakes. If the same model wrote both the function and its tests, the tests may encode the same misunderstanding. A passing suite from the same author is weak evidence.
The checks in this article fall into layers, and each layer answers a different question.
| Layer | Question | Independent of the generator? | Section |
|---|---|---|---|
| Behavioral tests | Does the code do what the specification says on inputs the author did not choose? | Only if the oracle is independent | Behavioral tests |
| Test-suite strength | Would the tests notice if the code were wrong? | Yes, the mutants come from outside | Behavioral tests |
| Dependency review | Is every new package one we intended to depend on? | Yes, if it checks against an approved list | Code and dependency review |
| Diff review | Is this the change that was asked for, and no more? | Yes, by a human or a rule | Code and dependency review |
| Security checks | Does the code contain known risky patterns? | Yes | Security checks |
| Reproduction | Can a failure be replayed without the generator? | Yes | Failure reproduction |
| Approval | Has an accountable person accepted the risk? | Yes | Human approval boundaries |
Behavioral tests
A candidate under test
The running example is a function that merges closed integer intervals, with touching intervals (for example 1 to 4 and 5 to 6) treated as one. The function below is a hypothetical candidate written to resemble plausible generated code. It looks reasonable and handles the obvious cases.
def merge_intervals(intervals):
"""Merge overlapping or touching closed integer intervals, e.g. [1,4] and [5,6] -> [1,6]."""
intervals.sort()
merged = [intervals[0]]
for start, end in intervals[1:]:
last = merged[-1]
if start <= last[1] + 1:
last[1] = end
else:
merged.append([start, end])
return merged
The reference implementation below fixes the three defects discussed later in this section. It is used only to validate the test machinery; in a real pipeline the reference may not exist, and the point of an oracle is to avoid needing one.
def merge_intervals(intervals):
"""Merge overlapping or touching closed integer intervals, e.g. [1,4] and [5,6] -> [1,6]."""
if not intervals:
return []
ordered = sorted([list(i) for i in intervals])
merged = [ordered[0]]
for start, end in ordered[1:]:
last = merged[-1]
if start <= last[1] + 1:
last[1] = max(last[1], end)
else:
merged.append([start, end])
return merged
The tests that usually accompany a generated function are examples: a few inputs with expected outputs. Two of them are enough to show the problem.
cases = [([[1, 3], [2, 6], [8, 10], [15, 18]], [[1, 6], [8, 10], [15, 18]]),
([[1, 4], [5, 6]], [[1, 6]])]
Both examples pass for the candidate and for the reference. Running the two examples against each gives:
example suite candidate: PASS | reference: PASS
A reviewer who sees a green check and two sensible examples has no signal that anything is wrong.
Properties and an independent oracle
A property is a statement that should hold for every valid input. Here there are three: the function does not modify its argument, it does not crash on valid input (including the empty list), and its output equals the answer given by an oracle. The oracle should be simple enough to trust and structurally different from the implementation. For intervals, one choice is to expand every interval into the set of integer points it covers and then read off maximal runs of consecutive points. It is slow, and for small inputs that does not matter.
def oracle(intervals):
"""Independent oracle: expand to integer points, then read off maximal runs."""
pts = sorted({p for a, b in intervals for p in range(a, b + 1)})
out = []
for p in pts:
if out and p == out[-1][1] + 1:
out[-1][1] = p
else:
out.append([p, p])
return out
The driver below generates random inputs from a seeded random number generator, including the empty list, runs the candidate on a copy, and records the first case that violates each property. Because the seed is fixed, the case numbers in the output are fixed too.
import copy, random
def gen(rng):
out = []
for _ in range(rng.randint(0, 6)):
a = rng.randint(0, 12)
out.append([a, a + rng.randint(0, 6)])
return out
def violations(fn, case):
before, work = copy.deepcopy(case), copy.deepcopy(case)
try:
got = fn(work)
except Exception as e:
return [f"raises {type(e).__name__}"]
found = []
if work != before:
found.append("mutates its input")
if got != oracle(case):
found.append("differs from oracle")
return found
rng, first = random.Random(2024), {}
for k in range(500):
case = gen(rng)
for prop in violations(merge_intervals, case):
first.setdefault(prop, (k, case))
for prop, (k, case) in first.items():
print(f"case #{k:<3d} {prop:20s} {case}")
Output for the candidate, with seed 2024 and 500 generated cases:
case #0 mutates its input [[2, 7], [9, 11], [3, 8]]
case #5 differs from oracle [[6, 6], [12, 14], [10, 13], [7, 7], [11, 16], [2, 8]]
case #22 raises IndexError []
Running the same loop on the reference implementation prints nothing, which validates the harness. Each failing case can also be shrunk, meaning the harness repeatedly removes intervals or moves endpoints toward zero while the same property keeps failing. The longer driver below does this with a hand-written greedy shrinker, an approximation of what libraries such as Hypothesis do: its documentation describes strategies for generating data, automatic shrinking to a minimal failing example, a local example database that replays earlier failures, and a derandomize setting for deterministic runs. Hypothesis itself was not run for this article.
# Longer driver: the example suite, the shrinker, and the property suite.
# It reuses oracle, gen, and violations from above; its violations() labels the
# second property "differs from point-set oracle".
def example_suite(fn):
"""The kind of example tests that usually accompany a generated function."""
cases = [([[1, 3], [2, 6], [8, 10], [15, 18]], [[1, 6], [8, 10], [15, 18]]),
([[1, 4], [5, 6]], [[1, 6]])]
for given, want in cases:
if fn(copy.deepcopy(given)) != want:
return False
return True
def shrink(fn, case, prop):
"""Greedy shrinker: drop intervals, then pull endpoints toward zero, while the same failure persists."""
changed = True
while changed:
changed = False
for i in range(len(case)):
cand = case[:i] + case[i + 1:]
if prop in violations(fn, cand):
case, changed = cand, True; break
else:
for i in range(len(case)):
for j in (0, 1):
for delta in (-1,):
cand = copy.deepcopy(case); cand[i][j] += delta
if cand[i][0] <= cand[i][1] and cand[i][0] >= 0 and prop in violations(fn, cand):
case, changed = cand, True; break
if changed: break
if changed: break
return case
def property_suite(fn, n=500, seed=2024, report=False):
rng = random.Random(seed)
found = {}
for k in range(n):
case = gen(rng)
for prop in violations(fn, case):
if prop not in found:
found[prop] = (k, case)
if report:
for prop, (k, case) in found.items():
small = shrink(fn, copy.deepcopy(case), prop)
print(f" case #{k:<3d} {prop:30s} original={case} shrunk={small}")
return not found
Its output for the candidate, with the original and the shrunk case for each property:
property suite candidate (seed 2024, 500 cases):
case #0 mutates its input original=[[2, 7], [9, 11], [3, 8]] shrunk=[[0, 1], [0, 0]]
case #5 differs from point-set oracle original=[[6, 6], [12, 14], [10, 13], [7, 7], [11, 16], [2, 8]] shrunk=[[1, 1], [0, 2]]
case #22 raises IndexError original=[] shrunk=[]
The shrunk cases are [[0, 1], [0, 0]], [[1, 1], [0, 2]], and [].
Each line is a distinct defect, and the shrunk case explains it.
- Mutates its input. The function sorts the caller’s list in place, and
mergedholds the caller’s own inner lists, so updatinglast[1]rewrites the input. The shrunk case[[0, 1], [0, 0]]is reordered by the sort. - Wrong result. On
[[1, 1], [0, 2]]the sorted order is[0, 2]then[1, 1]. The second interval is entirely inside the first, but the code sets the end to 1 instead of taking the larger of the two ends, so the result is[0, 1]and the oracle says[0, 2]. The two example tests never contain an interval nested inside another, so they could not find this. - Crash on empty input.
intervals[0]fails on an empty list, a valid input the examples did not include.
Reading the result carefully: the property suite found three defects in a function the example suite passed. That does not mean property-based tests find all defects. They find violations of the properties you wrote, over the inputs you generate. Here the generator draws values between 0 and 18 and lists of up to 6 intervals, so it would not find a defect that needs large numbers, negative values, or long lists. Write the generator’s domain down and widen it deliberately.
Is the test suite strong enough? A mutation check
The property suite caught the bugs, but you should ask the opposite question as well: would a suite notice if the code were wrong? Mutation testing answers it by making small deliberate changes (mutants) to correct code and checking whether the tests fail. A mutant that no test detects points to a behavior the suite does not check. A harness applies seven hand-written mutations to the reference implementation, such as changing <= to <, replacing max with min, removing the sort, and removing the empty-input guard, and runs both suites against each. The harness applies each mutation by textual replacement in the reference source and uses the example and property suites of the longer driver.
from suites import example_suite, property_suite
src = open("reference.py").read()
mutants = [
("touching intervals no longer merge", "start <= last[1] + 1", "start < last[1] + 1"),
("gap tolerance off by one", "start <= last[1] + 1", "start <= last[1] + 2"),
("max -> min when extending", "max(last[1], end)", "min(last[1], end)"),
("max removed (keeps last end)", "max(last[1], end)", "end"),
("sort removed", "sorted([list(i) for i in intervals])", "[list(i) for i in intervals]"),
("input not copied", "sorted([list(i) for i in intervals])", "sorted(intervals)"),
("empty-input guard removed", "if not intervals:", "if False:"),
]
def build(s):
ns = {}; exec(s, ns); return ns["merge_intervals"]
ex_killed = pr_killed = 0
print(f"{'mutant':38s} {'examples':>9s} {'properties':>11s}")
for name, a, b in mutants:
assert a in src
fn = build(src.replace(a, b, 1))
ex_ok, pr_ok = example_suite(fn), property_suite(fn, n=500)
ex_killed += (not ex_ok); pr_killed += (not pr_ok)
print(f"{name:38s} {'survived' if ex_ok else 'killed':>9s} {'survived' if pr_ok else 'killed':>11s}")
n = len(mutants)
print(f"\nmutation score: examples {ex_killed}/{n} = {ex_killed/n:.0%}; properties {pr_killed}/{n} = {pr_killed/n:.0%}")
mutant examples properties
touching intervals no longer merge killed killed
gap tolerance off by one killed killed
max -> min when extending killed killed
max removed (keeps last end) survived killed
sort removed survived killed
input not copied survived killed
empty-input guard removed survived killed
mutation score: examples 3/7 = 43%; properties 7/7 = 100%
The example suite lets four of seven mutants survive, which agrees with what the earlier experiment showed: the examples do not test nesting, ordering, input mutation, or emptiness. The property suite kills all seven. Three cautions apply to reading that table.
- Seven mutants is a toy sample. Real tools generate mutants automatically across a code base, and some mutants are equivalent to the original, meaning no test could ever distinguish them. A score below 100 percent is not always a defect.
- A mutation score measures the suite against the mutations tried. It says nothing about faults that the mutations do not resemble.
- Mutation runs cost time, because the suite runs once per mutant. Use them on changed code or on critical modules instead of every file on every commit.
There is evidence from industry that the idea is useful. Petrović and colleagues, studying mutation testing at scale, reported that developers who used it wrote more tests and improved their suites so that fewer mutants remained, and that mutants were coupled with real faults, so a live mutant could have flagged changes that later introduced bugs. That is a result from one organization’s practice and tooling, but it supports using surviving mutants as a signal on generated changes, where the question of whether the tests are meaningful is most pressing.
Setting up the tests so they stay trustworthy
- Keep the oracle and the implementation independent. Prefer a specification-derived oracle, a different algorithm, a known invariant, or a table of hand-computed answers. A test fixture supplies controlled starting state and can also provide expected values. Those values must come from a rule independent of the implementation under test.
- Fix the randomness and record it. Use a seed that appears in the log. With a framework, set deterministic generation for CI runs or persist failing cases, as the Hypothesis documentation describes.
- Check the checker. Run the suite against a deliberately wrong variant, as the mutation check did, and keep the result.
- Watch for weakened tests. A generated change that makes a failing test pass by deleting the assertion, adding a skip, or loosening a comparison is a different kind of failure from a code bug. The approval gate below detects it from the diff.
- Do not equate coverage with checking. A line executed by a test with no meaningful assertion counts as covered. Pair coverage with a measure of assertion strength, such as mutation score on the changed code.
- Treat flaky tests as defects in the evidence. If a test sometimes fails for reasons unrelated to the change, a red check no longer tells reviewers anything, and a rerun-until-green habit lets real failures through.
Code and dependency review
Reviewing the diff
Tests answer whether behavior matches a specification. Review answers a different question: is this the change that was asked for? Generated changes tend to fail review in recognizable ways, and a checklist makes the review focus on them.
- Scope. Did the diff touch files unrelated to the request, reformat code, rename things, or modify configuration and pipeline files?
- Removed protection. Were validation, error handling, authorization checks, logging, or tests removed or weakened?
- Swallowed errors. Do new
exceptblocks hide failures, return defaults that mask them, or catch everything? - New dependencies and calls. Does it add packages, shell commands, network calls, or file writes?
- Names and claims. Do the docstring and comments describe what the code does, or what the function was asked to do? A comment that says “touching intervals are merged” is not evidence that they are.
- Size. Is the diff small enough to review properly? An approval of a large diff is a weak signal.
Some of these checks can be automated, and the approval section shows two. The rest need a person with enough context to know what the change should have been.
Dependency review
Generated code that imports a package the project has never used creates a decision that nobody made. The risk has two forms: the package exists and is unreviewed, or it does not exist and an attacker could create it. The second form is the package-hallucination scenario in the study above. It also resembles dependency confusion, in which a build resolves a name to an attacker-controlled package. The common defense is to make dependency resolution depend on an approved set and not on whatever a name resolves to.
The controls to combine:
- An approved list that is the source of truth, with a reviewer and a reason for each entry. A dependency lock file records the resolved dependency graph so that it can be reviewed and reused. A new package must appear in a reviewed change to the lock file.
- Hash checking where your package manager supports it. pip’s hash-checking mode requires hashes for all requirements, requires pins, and covers transitive dependencies, so a requirement that is not hashed makes the install fail. Its documentation describes the protection as against tampering and corruption of downloads. It does not decide whether a package should be trusted in the first place; it verifies that what you install is what you reviewed.
- A registry policy such as installing only through an internal mirror that serves approved packages.
- An import check in CI that compares the imports in changed files with the approved set.
The last is simple to build and worth seeing. The check parses Python source with the ast module, collects top-level imported module names, and classifies each one as standard library, first-party, approved, or unapproved. For unapproved names it looks for a close match among approved names, a heuristic for misspellings. The sample input is synthetic: it imports flask_jwt_helper (a made-up name) and reqeusts (a misspelling of an approved package).
import ast, difflib, sys
APPROVED = {"requests", "flask", "sqlalchemy"} # from the project's reviewed lock file (synthetic)
STDLIB = set(sys.stdlib_module_names)
SAMPLE = '''
import json
import requests
from flask import Flask
from flask_jwt_helper import verify_token # not in the approved set
import reqeusts # near-miss of an approved name
from app.utils import slugify # first-party module
'''
FIRST_PARTY = {"app"}
def top_level_imports(src):
mods = set()
for node in ast.walk(ast.parse(src)):
if isinstance(node, ast.Import):
mods |= {a.name.split(".")[0] for a in node.names}
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
mods.add(node.module.split(".")[0])
return mods
for mod in sorted(top_level_imports(SAMPLE)):
if mod in STDLIB: status = "stdlib"
elif mod in FIRST_PARTY: status = "first-party"
elif mod in APPROVED: status = "approved"
else:
near = difflib.get_close_matches(mod, APPROVED, n=1, cutoff=0.8)
status = "UNAPPROVED" + (f" (close to approved '{near[0]}')" if near else " (needs dependency review)")
print(f"{mod:16s} {status}")
app first-party
flask approved
flask_jwt_helper UNAPPROVED (needs dependency review)
json stdlib
reqeusts UNAPPROVED (close to approved 'requests')
requests approved
The check identifies standard library modules using sys.stdlib_module_names, which Python documents as a frozenset of standard library module names, added in version 3.10 and listing only the main package for packages (for example email but not email.mime). Because only top-level names are collected here, that limit does not matter. Other limitations do:
- An import name is not always the package name.
yamlis installed by a package with a different name, so a real checker needs a mapping from import names to distribution names. - Dynamic imports, plugin loading by string, and requirements installed by shell commands are invisible to a static import scan.
- Similarity matching produces false positives and misses names that are not close to any approved name. It is a hint for the reviewer and not a decision procedure.
- The check does not tell you whether an approved package has known vulnerabilities. That is a separate scan, and an accepted exception for a known vulnerability should follow the practice described under vulnerability exceptions: a named approver, a scope, a rationale, and an expiry.
Security checks
Security checks look for patterns that tests of intended behavior do not exercise. They are a set of different tools, none complete.
- Static analysis examines the source without running it. Bandit, a security linter for Python, builds an abstract syntax tree from each file and runs plugins against the nodes, according to its documentation. Bandit was not run for this article, and its specific rule set was not verified.
- Secret scanning looks for credentials in the diff and, ideally, the history. Its coverage depends on the patterns it knows, as the earlier article’s gateway model showed.
- Dependency vulnerability scanning matches the resolved dependency set against advisory databases. It depends on data that changes daily and needs network access, so this article does not demonstrate it.
- Tests of security behavior exercise authorization, input handling, and error paths with inputs designed to fail. A static rule can flag a risky pattern; only a test shows that an authorization check holds.
To show what a static rule is, the next check implements five rules with the ast module and runs them on a synthetic sample that includes risky code and some lookalikes. It is a teaching model and not a substitute for a maintained tool. The sample source is part of the listing, so the line numbers in the output refer to it.
import ast, re
SAMPLE = '''import hashlib, subprocess, requests
def lookup(db, name):
return db.execute(f"SELECT * FROM users WHERE name = '{name}'")
def run(cmd):
return subprocess.run(cmd, shell=True)
def fetch(url):
return requests.get(url, verify=False)
PASSWORD = "hunter2-placeholder"
def cache_key(s):
return hashlib.md5(s.encode()).hexdigest()
def safe_lookup(db, name):
return db.execute("SELECT * FROM users WHERE name = ?", (name,))
'''
SECRET_NAME = re.compile(r"(pass(word)?|secret|token|api_?key)", re.I)
def findings(src):
out = []
for n in ast.walk(ast.parse(src)):
if isinstance(n, ast.Call):
f = n.func
name = f.attr if isinstance(f, ast.Attribute) else getattr(f, "id", "")
kw = {k.arg: k.value for k in n.keywords}
if name == "execute" and n.args and isinstance(n.args[0], (ast.JoinedStr, ast.BinOp)):
out.append((n.lineno, "sql-string-building", "query text built from a string expression"))
if name in ("run", "call", "Popen", "check_output") and isinstance(kw.get("shell"), ast.Constant) and kw["shell"].value is True:
out.append((n.lineno, "shell-true", "shell=True passes the command through a shell"))
if isinstance(kw.get("verify"), ast.Constant) and kw["verify"].value is False:
out.append((n.lineno, "tls-verify-off", "certificate verification disabled"))
if name == "md5":
out.append((n.lineno, "weak-hash", "md5 used; acceptable for a cache key, not for security"))
if isinstance(n, ast.Assign) and isinstance(n.value, ast.Constant) and isinstance(n.value.value, str):
for t in n.targets:
if isinstance(t, ast.Name) and SECRET_NAME.search(t.id):
out.append((n.lineno, "hardcoded-secret-name", f"string literal assigned to {t.id}"))
return sorted(out)
for line, rule, msg in findings(SAMPLE):
print(f"line {line:2d} {rule:22s} {msg}")
line 3 sql-string-building query text built from a string expression
line 5 shell-true shell=True passes the command through a shell
line 7 tls-verify-off certificate verification disabled
line 8 hardcoded-secret-name string literal assigned to PASSWORD
line 10 weak-hash md5 used; acceptable for a cache key, not for security
The sample contained six functions or assignments of interest. The checker reported five findings, and each tells something different.
- Lines 3, 5, and 7 are real risk patterns in the sample: a query assembled with an f-string, a command run through a shell, and verification turned off. These are the kinds of findings a reviewer wants flagged. Whether the first one is exploitable depends on where
namecomes from, which a pattern check does not know. - Line 8 flags a string assigned to a variable named
PASSWORD. The value in the sample is a placeholder, but the rule has no way to know that. A hardcoded credential in real code would look identical. - Line 10 flags
md5. In the sample it is a cache key, where a weak hash is not a vulnerability, so this is a false positive that a human must triage. Findings need a suppression process that records who decided and why, so a suppression does not become silent permission. - The parameterized query in
safe_lookupwas not flagged, which is the intended behavior.
What the checker cannot do: it matches syntax. It does not track whether data from a user reaches the query (taint analysis), and it would miss the same bug written another way, for example by building the query in a helper function first. A clean report means no matches for the rules, not the absence of vulnerabilities. To judge a scanner, run it on a set of known-bad samples and on the lookalikes that should not match, and keep those samples as regression tests for the pipeline. Choose a maintained tool for production and tune its rules to your language and frameworks.
Prompt injection deserves one more mention in this layer. If the context the tool read contained instructions planted by someone untrusted, the generated change may do what those instructions asked. Prompt injection is the attempt to make a model follow instructions that conflict with its task. The pipeline’s defenses are the ones described here: dependency and diff review for unexpected additions, and least-privilege CI jobs for changes from the tool, which run without deployment credentials and cannot change their own pipeline definition without an approval.
Failure reproduction
A generator is not a reliable way to reproduce a failure. Asking the tool again for the code may give a different result, and the original session may not be recoverable. The pipeline needs to produce an artifact from which anyone can replay the failure without the generator.
A useful failure record contains:
- The exact input that triggered the failure, after shrinking where possible.
- The seed and the index of the generated case, if randomness was involved.
- The command and the version of the language, test runner, and dependencies, or the lock file hash.
- The commit or diff hash of the change under test.
- Identifying information about how the change was produced, such as the tool and configuration version recorded by the pipeline from the previous article’s audit trail, as metadata. This is not enough to regenerate the change, and it should not be treated as such.
The reproduction step finds the first case on which the candidate differs from the oracle, writes that record to a JSON file, and then replays the failure from the file alone, without the random number generator. It also replays the same input against the reference to show that the input discriminates between the two implementations. The script imports the generator and the property checker from the longer driver.
import json, random, sys, importlib.util
from suites import violations, gen
def load(path):
spec = importlib.util.spec_from_file_location("m", path); m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); return m.merge_intervals
cand = load("candidate.py")
rng = random.Random(2024); record = None
for k in range(500):
case = gen(rng)
v = violations(cand, case)
if "differs from point-set oracle" in v:
record = {"seed": 2024, "case_index": k, "input": case, "violations": v, "python": sys.version.split()[0], "target": "candidate.py"}
break
json.dump(record, open("failure.json", "w"), indent=1)
print(json.dumps(record))
# replay from the recorded artifact only (no RNG)
rec = json.load(open("failure.json"))
print("replay violations:", violations(cand, rec["input"]))
print("replay on reference:", violations(load("reference.py"), rec["input"]))
{"seed": 2024, "case_index": 5, "input": [[6, 6], [12, 14], [10, 13], [7, 7], [11, 16], [2, 8]], "violations": ["mutates its input", "differs from point-set oracle"], "python": "3.12.14", "target": "candidate.py"}
replay violations: ['mutates its input', 'differs from point-set oracle']
replay on reference: []
The replay shows that a recorded input reproduces the failure deterministically and that the corrected implementation passes on it. Property-based testing libraries offer comparable features. Hypothesis documents a decorator that embeds an encoded failing case so a failure from CI can be reproduced locally without the database or the seed, and an @example decorator that keeps specific cases in the suite permanently. That is how a found failure becomes a regression test: shrink it, add it as an explicit example, and keep it.
Reproduction also needs a triage step, because failures differ in what you do next:
| Failure type | Example | Next action |
|---|---|---|
| Wrong behavior | Nested interval merged incorrectly | Add the shrunk case as a regression test, fix the code, rerun the full property and mutation checks |
| Missing behavior | Empty input not handled | Update the specification or acceptance criteria if the omission was the requester’s, then add a test |
| Unsafe pattern | Query built from a string | Fix, and add a negative test that exercises hostile input |
| Dependency problem | Unapproved import | Reject, or run dependency review and add to the approved list with a reason |
| Evidence problem | Test removed or weakened | Restore it; require an explanation for any change |
| Flaky | Passes on rerun | Quarantine with an owner and a deadline; do not count it as a pass |
Whether to regenerate with the tool or fix by hand is a team decision. If you regenerate, give the tool the shrunk failing case as part of the request, and run the same checks on the new result. Do not carry approval from the first version to the second.
Human approval boundaries
Automation can check formatting, run tests, scan for patterns, and compare imports to a list. It cannot decide whether a change matches the requester’s intent, whether a risk is acceptable, or whether a missing test matters. Those are decisions for a person who is accountable for the result. The design question is where a person must be in the loop and how to make the rest of the process support that person’s attention.
What branch rules can enforce
Server-side repository rules are the backstop because they do not depend on the client or the tool. GitHub’s documentation for protected branches describes several relevant settings: requiring a number of approving reviews before merge, dismissing stale approvals when new commits change the diff, requiring review from code owners, requiring that the most recent reviewable push be approved by someone other than the person who pushed it, and requiring status checks to pass. It also states that by default protections do not apply to administrators or custom roles with a bypass permission, unless the repository is configured to disallow bypass. Those details are the failure modes of the control: an approval made before a later push can be left standing if stale approvals are not dismissed, and a person who can both push and approve can act alone.
The point about acting alone is separation of duties: limiting the ability of one actor to misuse a sensitive process alone. For generated code, it implies that the person who prompted the tool and submitted the change should not be the only approver, and the tool itself is not an approver. The tool is a source of proposals; accountability rests with the person who submits it and the person who approves it.
CODEOWNERS files assign owners to paths. GitHub’s documentation notes that the file must be on the pull request’s base branch to produce review requests, that patterns follow gitignore rules with exceptions (negation with ! does not work), that the last matching pattern takes precedence, and that when code owner review is required an approval from any one of the owners is sufficient. Two consequences matter. A broad pattern placed after a specific one overrides it, so ordering mistakes can remove an owner from a sensitive path. And a team owner means one member’s approval suffices, so a team with automatic approval habits adds little. The documentation also suggests giving the CODEOWNERS file an owner of its own, because otherwise a change to it can alter who must review everything else.
A gate that classifies diffs
Branch rules apply the same requirement to every change. A gate that inspects the diff can require more for particular changes. The gate classifies a unified diff into three outcomes: standard review, code-owner approval for sensitive paths, and a block pending explanation when tests lose assertions or gain skip markers. It also flags unusually large diffs. The path list and thresholds are assumptions for the example.
The rules in the example, which are assumptions and not recommendations for your repository:
- Sensitive paths need a code owner: workflow definitions, database migrations, an
auth/directory,Dockerfile, dependency manifests and lock files, the AI tool’s own configuration directory, and the CODEOWNERS file. - Weakened tests block the change until someone explains it: a file under
tests/that loses more assertion lines than it gains, or that adds a skip or expected-failure marker. Assertion lines are found with a regular expression forassertandself.assert. - Size above 400 changed lines is flagged.
The gate and three small synthetic diffs are listed below.
import fnmatch, re
HUMAN_PATHS = [".github/workflows/**", "**/migrations/**", "auth/**", "Dockerfile", "requirements*.txt",
"pyproject.toml", "*.lock", ".claude/**", "CODEOWNERS"]
def match(path, pats):
return any(fnmatch.fnmatchcase(path, p) or fnmatch.fnmatchcase(path, p.replace("**/", "")) for p in pats)
def parse(diff):
files, cur = {}, None
for line in diff.splitlines():
if line.startswith("+++ "):
cur = line[4:].removeprefix("b/"); files[cur] = {"added": [], "removed": [], "deleted": False}
elif cur and line.startswith("+") and not line.startswith("+++"):
files[cur]["added"].append(line[1:])
elif cur and line.startswith("-") and not line.startswith("---"):
files[cur]["removed"].append(line[1:])
return files
ASSERT = re.compile(r"\bassert\b|self\.assert")
SKIP = re.compile(r"pytest\.mark\.(skip|xfail)|@unittest\.skip")
def classify(diff, max_lines=400):
files, reasons = parse(diff), []
total = sum(len(f["added"]) + len(f["removed"]) for f in files.values())
for path, f in files.items():
if match(path, HUMAN_PATHS):
reasons.append(f"code-owner approval: sensitive path {path}")
if path.startswith("tests/"):
lost = sum(bool(ASSERT.search(l)) for l in f["removed"]) - sum(bool(ASSERT.search(l)) for l in f["added"])
if lost > 0:
reasons.append(f"BLOCK pending explanation: {path} loses {lost} net assertion line(s)")
if any(SKIP.search(l) for l in f["added"]):
reasons.append(f"BLOCK pending explanation: {path} adds a skip/xfail marker")
if total > max_lines:
reasons.append(f"size: {total} changed lines exceeds {max_lines}")
return reasons or ["standard review"]
PRS = {
"PR-1 (small refactor)": """+++ b/src/pricing.py
+ return round(total, 2)
- return total
+++ b/tests/test_pricing.py
+ assert price(3) == 3.0
""",
"PR-2 (fix + dependency + test change)": """+++ b/src/client.py
+import flask_jwt_helper
+++ b/requirements.txt
+flask-jwt-helper==1.0.0
+++ b/tests/test_client.py
- assert client.get('/a').status == 200
- assert client.get('/b').status == 200
+ assert client.get('/a').status == 200
+ @pytest.mark.skip
""",
"PR-3 (workflow edit)": """+++ b/.github/workflows/ci.yml
+ - run: ./deploy.sh
""",
}
for name, diff in PRS.items():
print(name)
for r in classify(diff): print(" -", r)
The output for the three diffs was:
PR-1 (small refactor)
- standard review
PR-2 (fix + dependency + test change)
- code-owner approval: sensitive path requirements.txt
- BLOCK pending explanation: tests/test_client.py loses 1 net assertion line(s)
- BLOCK pending explanation: tests/test_client.py adds a skip/xfail marker
PR-3 (workflow edit)
- code-owner approval: sensitive path .github/workflows/ci.yml
PR-2 shows how the layers combine. It adds a dependency, which sends the change to an owner, and it removes an assertion while adding a skip marker, which is exactly the “make the test pass” pattern. PR-3 changes the pipeline definition, and a change that can alter what CI runs deserves a code owner’s review even when it is one line.
The gate’s limits are important. It uses file paths and text patterns, so a renamed test directory or an assertion style the regular expression does not recognize escapes it. It can be gamed by splitting a change into small pieces. It needs to run in a place the author cannot edit, and a change to the gate’s own configuration should require the same approval as anything else it protects. Treat it as a way to focus human attention, not as a replacement for it.
What a human approval has to mean
An approval is only as good as what the approver did. Several practices raise its value without claiming to guarantee it:
- Give the approver the evidence. The review page should show the test results, the mutation or property findings on the changed code, the dependency report, and the security findings next to the diff, so that the approver is reviewing evidence and not rediscovering it.
- Keep changes reviewable. Set a size threshold above which the change must be split. A reviewer cannot give attention to a diff that exceeds what attention can cover, and a rubber-stamp approval is worse than none because it creates false assurance.
- Require the author to state the intent and the risk. The submitter explains what was asked, what was changed, what was verified, and what was not. A submitter who cannot explain the diff should not submit it.
- Record the approver’s scope. If an approver reviewed only the security-sensitive part, say so.
- Define the exception path. Time-limited, owner-approved, recorded.
These are recommendations that follow from the reasoning above. This article does not present measurements showing how much each practice improves outcomes; evidence on reviewer behavior with generated code is something to collect in your own organization before relying on any of them.
Assembling the pipeline
A pipeline for changes from AI coding tools runs in this order, from cheapest and most reliable to most expensive and judgment-dependent:
- Provenance and scope checks. Record how the change was produced, and classify the paths touched. Run the approval gate. Fail fast on unapproved imports and on weakened tests.
- Build and example tests from the reviewed lock file, with hash checking where supported.
- Property-based tests with a documented generator domain, a recorded seed, and an independent oracle, on the changed code.
- Mutation check on changed code or critical modules, with a threshold appropriate to the module and a way to mark equivalent mutants.
- Static analysis, secret scan, and dependency vulnerability scan, with a suppression process.
- Security behavior tests for any change touching authorization, input handling, or data access.
- Human approval according to the gate and the repository rules, with the evidence attached.
- Failure artifacts published for every red build, containing the reproduction record.
Each stage should produce an artifact, because evidence that cannot be inspected later cannot support an investigation. Stages should be configured so that skipping one is visible: a check that did not run is not a check that passed.
Limits of this approach
The examples are small and synthetic. The function has a clean specification and a simple oracle, which many real functions lack; for those, you may rely on invariants, differential testing between implementations, or recorded production inputs, each with its own weaknesses. The property generator covered a narrow domain. The mutation experiment used seven mutants. The dependency check is a heuristic. The security rules are a teaching model. The gate depends on conventions about paths and test layout. Published studies of generated code cover particular tools, versions, and methods, and results for current tools may differ. None of these checks proves a change correct. They reduce specific, nameable risks and make the remaining decisions visible to the person who must make them. The next article in the series, Operating Coding Agents with Restricted Permissions, turns to what the tool is allowed to do while it works, which is the permission side of the same problem.
References
- Pearce et al., Asleep at the Keyboard? Assessing the Security of GitHub Copilot’s Code Contributions (arXiv 2108.09293)
- Spracklen et al., We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs (arXiv 2406.10279)
- Petrović et al., Does mutation testing improve testing practices? (arXiv 2103.07189)
- Hypothesis documentation: API reference
- Python documentation: sys.stdlib_module_names
- pip documentation: Secure installs and hash-checking mode
- Bandit documentation
- GitHub Docs: About protected branches
- GitHub Docs: About code owners
Discover more from Insightful Data Lab
Subscribe to get the latest posts sent to your email.
