Part II: Discovery Through Software Engineering and Vibe Coding
Chapter 16: AI-Assisted Implementation at Repository Scale

16.2 Impact Analysis and Patch Review

"I changed one line in the base class. Fourteen services went down. The on-call engineer described the experience as 'educational.'"

A Refactoring Tool With Insufficient Graph Coverage
The Big Picture

A one-line signature change in a base class can silently invalidate 200 downstream files, and without a way to trace that ripple before you merge, you will not discover the damage until production pages start failing. Impact analysis computes the set of files, functions, and tests affected by a proposed change, using the dependency graph as a propagation medium. Patch review then evaluates whether the change handles all affected sites correctly. Together, these two capabilities transform code generation from "produce text that looks right" to "produce text with quantified risk." This section builds both systems from scratch, starting with graph-theoretic foundations and arriving at a practical review pipeline that combines static analysis, test execution, and large language model (LLM)-based semantic review.

1. Change Propagation in Dependency Graphs

In 2017, a routine update to a widely shared utility module at a major tech company cascaded through 14 production services before anyone realized a single renamed parameter was the root cause. The cost of that blind merge was three hours of partial downtime and a full week of post-mortem meetings. The technique that prevents such failures is change propagation analysis, which traces every downstream consumer of a modified file before the change ever lands.

Section 16.1 introduced the edit graph: a directed acyclic graph (DAG) of planned edits with dependency edges. Impact analysis inverts this perspective. Instead of asking "what order should I generate edits?", it asks "given a change to file \(f\), which other files might break?" The answer is the transitive closure (the set of all nodes reachable by following edges forward through any number of intermediate nodes) of the reverse dependency graph rooted at \(f\).

Formally, let \(G = (V, E)\) be the dependency graph where \(V\) is the set of files and \((u, v) \in E\) means "file \(v\) imports from file \(u\)." The blast radius of a change to file \(f\) is the set of files reachable from \(f\) in \(G\):

The blast radius is a precise graph-theoretic quantity: the set of all nodes reachable from a changed file by following dependency edges forward (from provider to consumer). A breadth-first or depth-first traversal computes it. Any file in this set could observe the change and break, so the blast radius defines the upper bound on what a reviewer must consider. The mechanism is transitive closure: if A imports B and B imports C, changing C puts both A and B in the blast radius, even though A never references C directly. Use the blast radius as a quick, coarse safety check before merging. When it is too large to review manually, switch to the finer-grained impact set (described below), which filters down to files that actually reference the specific symbols you changed.

$$ \text{blast}(f) = \{v \in V \mid \exists \text{ path } f \to v \text{ in } G\} $$

Refining the Blast Radius

The blast radius is an upper bound on the set of files that could break. Not every file in the blast radius will actually break; only those that reference the specific symbols being changed are truly at risk. We refine the blast radius into the impact set by filtering for files that actually use the changed symbols:

$$ \text{impact}(f, S) = \{v \in \text{blast}(f) \mid \text{refs}(v) \cap S \neq \emptyset\} $$

where \(S\) is the set of symbols modified in file \(f\) and \(\text{refs}(v)\) is the set of symbols that file \(v\) references from \(f\) (directly or transitively). This refinement often reduces the set by 60-80% in practice, focusing attention on the files that actually need inspection. In short: The blast radius tells you the worst case; the impact set tells you the real case.

Mental Model

Think of the blast radius like a recall notice for a single ingredient at a food manufacturer. If the flour supplier reports a contamination, the blast radius is every product line that uses flour anywhere in its recipe chain, even indirectly (a cake mix uses flour; a frozen dinner uses the cake mix). That could be hundreds of products. The impact set is the subset where the specific contaminated batch actually shipped: only the production runs that received lot #4417. The recall team does not pull every flour-containing product off shelves; they trace the specific lot through the supply chain and pull only the affected items. Similarly, impact analysis traces specific changed symbols through the import graph rather than flagging every transitive dependent.

from collections import defaultdict, deque
from dataclasses import dataclass, field
from pathlib import Path

@dataclass
class DependencyGraph:
    """Repository-level dependency graph for impact analysis."""

    # adjacency list: file -> set of files that import from it
    dependents: dict[str, set[str]] = field(
        default_factory=lambda: defaultdict(set)
    )
    # reverse: file -> set of files it imports from
    dependencies: dict[str, set[str]] = field(
        default_factory=lambda: defaultdict(set)
    )
    # symbol-level: (file, symbol) -> set of (file, line) that reference it
    symbol_refs: dict[tuple[str, str], set[tuple[str, int]]] = field(
        default_factory=lambda: defaultdict(set)
    )

    def add_dependency(self, importer: str, imported: str) -> None:
        """Record that 'importer' imports from 'imported'."""
        self.dependents[imported].add(importer)
        self.dependencies[importer].add(imported)

    def add_symbol_ref(
        self, source_file: str, symbol: str,
        referencing_file: str, line: int
    ) -> None:
        """Record that referencing_file uses symbol from source_file."""
        self.symbol_refs[(source_file, symbol)].add(
            (referencing_file, line)
        )

    def blast_radius(self, changed_file: str) -> set[str]:
        """Compute all files transitively dependent on changed_file."""
        visited = set()
        queue = deque([changed_file])

        while queue:
            current = queue.popleft()
            if current in visited:
                continue
            visited.add(current)
            for dep in self.dependents.get(current, set()):
                if dep not in visited:
                    queue.append(dep)

        visited.discard(changed_file)  # exclude the changed file itself
        return visited

    def impact_set(
        self, changed_file: str, changed_symbols: set[str]
    ) -> dict[str, list[tuple[str, int]]]:
        """Compute files that reference specific changed symbols.

        Returns: {file_path: [(symbol, line), ...]} for affected files.
        """
        affected: dict[str, list[tuple[str, int]]] = defaultdict(list)

        for symbol in changed_symbols:
            key = (changed_file, symbol)
            for ref_file, ref_line in self.symbol_refs.get(key, set()):
                affected[ref_file].append((symbol, ref_line))

        return dict(affected)

    def impact_report(
        self, changed_file: str, changed_symbols: set[str]
    ) -> str:
        """Generate a human-readable impact report."""
        blast = self.blast_radius(changed_file)
        impact = self.impact_set(changed_file, changed_symbols)

        lines = [
            f"Impact Analysis for: {changed_file}",
            f"Changed symbols: {', '.join(sorted(changed_symbols))}",
            f"Blast radius: {len(blast)} files",
            f"Directly affected: {len(impact)} files",
            "",
        ]

        for file_path, refs in sorted(impact.items()):
            lines.append(f"  {file_path}:")
            for symbol, line in sorted(refs, key=lambda x: x[1]):
                lines.append(f"    Line {line}: references '{symbol}'")

        unaffected = blast - set(impact.keys())
        if unaffected:
            lines.append(f"\n  {len(unaffected)} files in blast radius "
                         f"but not directly affected:")
            for f in sorted(unaffected)[:5]:
                lines.append(f"    {f}")
            if len(unaffected) > 5:
                lines.append(f"    ... and {len(unaffected) - 5} more")

        return "\n".join(lines)
Listing 16.7: Dependency graph with blast radius computation (breadth-first search (BFS) transitive closure) and symbol-level impact analysis.

The symbol_refs dictionary in this class stores which files reference each symbol, but we have not yet shown how to populate it. Section 2 below builds a complete symbol reference index using tree-sitter to scan a repository and fill in these cross-references automatically.

Key Insight: Blast Radius vs. Impact Set

The blast radius answers "what could break?" while the impact set answers "what will break?" The distinction matters enormously for review efficiency. Renaming a method on a base class used by 200 files produces a blast radius of 200, but if only 12 files actually call that method, the impact set is 12. Reviewing 12 files is feasible; reviewing 200 is not. Symbol-level tracking reduces the review burden by an order of magnitude, which is the difference between an automated review that completes in seconds and one that times out.

Real-World Application: Google's Tricorder Static Analysis Platform

Google's Tricorder system performs impact analysis on every changelist submitted to the monorepo (a single repository that contains the code for many projects and services), computing blast radii across millions of files in seconds by maintaining a precomputed dependency graph that is incrementally updated with each commit. When an engineer modifies a protocol buffer definition (a language-neutral schema format for serializing structured data), Tricorder traces the generated code through the build graph to identify every binary and test that transitively depends on the changed schema, then runs only those affected tests. This selective execution reduces average continuous integration (CI) time from hours to minutes for changes touching widely shared libraries.

Exercise 16.2.1

Given a dependency graph where file A imports from B, B imports from C, and D imports from both A and C, list the blast radius and impact set for a change that renames function process() in file C. Assume that A references process() transitively through B's re-export, B calls process() directly, and D calls process() directly from C but does not use anything from A.

Hint

The blast radius follows all forward edges from C (who depends on C, directly or transitively?). The impact set filters to only those files that actually reference the renamed symbol process(). D imports from A as well, but that edge does not matter for the impact set because D's reference to process() comes from C, not from A.

Step-Through: Blast Radius BFS Traversal

Trace through the blast_radius() BFS with a five-file graph. Dependency edges (provider to consumer): utils.py is imported by models.py and views.py; models.py is imported by views.py and tests/test_models.py; views.py is imported by tests/test_views.py. We change utils.py.

Step 1: Queue = [utils.py], Visited = {}.
Step 2: Pop utils.py, mark visited. Its dependents are {models.py, views.py}. Queue = [models.py, views.py], Visited = {utils.py}.
Step 3: Pop models.py, mark visited. Its dependents are {views.py, tests/test_models.py}. Queue = [views.py, tests/test_models.py], Visited = {utils.py, models.py}.
Step 4: Pop views.py, mark visited. Its dependents are {tests/test_views.py}. Queue = [tests/test_models.py, tests/test_views.py], Visited = {utils.py, models.py, views.py}.
Step 5: Pop tests/test_models.py, mark visited. No dependents. Queue = [tests/test_views.py], Visited += test_models.
Step 6: Pop tests/test_views.py, mark visited. No dependents. Queue empty.
Result: Blast radius = {models.py, views.py, tests/test_models.py, tests/test_views.py} (4 files). Discard utils.py itself.

Common Misconception

A frequent mistake is treating the blast radius as the actual risk of a change, concluding that a blast radius of 400 files means the change is too dangerous to attempt. The blast radius is an upper bound, not a prediction. What determines actual risk is the impact set: how many files reference the specific symbols you are modifying. A base utility module may have a blast radius spanning most of the repository, yet a carefully scoped change (renaming a private helper, adjusting a default parameter) can have an impact set of zero. Abandoning a refactoring because of a large blast radius, without computing the impact set, leaves valuable improvements on the table.

2. Building the Symbol Reference Index

Computing the impact set requires more than the file-level dependency graph; it demands a symbol-level index that records exactly which files reference each function, class, and variable.

Computing the impact set requires knowing which files reference each symbol and at which lines. Tree-sitter, an incremental parsing library that builds concrete syntax trees for source files without executing them, scans every file, extracts definitions and usages, and cross-references them through the import graph, producing a batch-oriented version of the symbol resolution that IDEs perform interactively.

import tree_sitter_python as tspython
from tree_sitter import Language, Parser

PY_LANGUAGE = Language(tspython.language())
parser = Parser(PY_LANGUAGE)

def build_symbol_index(repo_root: str) -> DependencyGraph:
    """Scan a Python repository and build a complete dependency graph."""
    graph = DependencyGraph()
    root = Path(repo_root)

    # Phase 1: collect all definitions and imports
    file_defs: dict[str, set[str]] = {}   # file -> defined symbols
    file_imports: dict[str, list[dict]] = {}  # file -> import list

    for py_file in root.rglob("*.py"):
        rel_path = str(py_file.relative_to(root))
        try:
            source = py_file.read_text(encoding="utf-8")
        except (UnicodeDecodeError, PermissionError):
            continue

        tree = parser.parse(source.encode("utf-8"))

        # Extract definitions
        defs = set()
        def_query = PY_LANGUAGE.query("""
            (function_definition name: (identifier) @name)
            (class_definition name: (identifier) @name)
            (assignment left: (identifier) @name)
        """)
        for node, _ in def_query.captures(tree.root_node):
            if node.parent.parent == tree.root_node:
                defs.add(node.text.decode("utf-8"))
        file_defs[rel_path] = defs

        # Extract imports
        imports = []
        import_query = PY_LANGUAGE.query("""
            (import_from_statement
                module_name: (dotted_name) @module
                name: (dotted_name) @name)
        """)
        for node, capture_name in import_query.captures(tree.root_node):
            imports.append({
                "capture": capture_name,
                "text": node.text.decode("utf-8"),
                "line": node.start_point[0] + 1,
            })
        file_imports[rel_path] = imports

    # Phase 2: resolve imports to file dependencies
    module_to_file: dict[str, str] = {}
    for rel_path in file_defs:
        # Convert file path to module path
        module = rel_path.replace("/", ".").replace("\\", ".")
        if module.endswith(".py"):
            module = module[:-3]
        if module.endswith(".__init__"):
            module = module[:-9]
        module_to_file[module] = rel_path

    for rel_path, imports in file_imports.items():
        current_module = None
        for imp in imports:
            if imp["capture"] == "module":
                current_module = imp["text"]
                target = module_to_file.get(current_module)
                if target:
                    graph.add_dependency(rel_path, target)
            elif imp["capture"] == "name" and current_module:
                target = module_to_file.get(current_module)
                if target:
                    graph.add_symbol_ref(
                        target, imp["text"], rel_path, imp["line"]
                    )

    return graph

# Example usage
# graph = build_symbol_index("/path/to/project")
# report = graph.impact_report(
#     "models/experiment.py",
#     changed_symbols={"Experiment", "__init__"}
# )
# print(report)
Listing 16.8: Building a complete symbol reference index from a Python repository using tree-sitter for definition and import extraction.

The two-phase approach (collect, then resolve) is necessary because Python's import system is dynamic: at scan time, you do not know which module a dotted name refers to until you have mapped all files to their module paths. Phase 1 builds the raw data; phase 2 resolves the cross-references. For repositories with complex package structures (namespace packages, conditional imports, re-exports through __init__.py), the resolution logic needs additional heuristics, but the two-phase structure remains the same.

Practical Example: Measuring Blast Radius in scikit-learn

The scikit-learn repository contains approximately 1,200 Python files. Running the dependency graph builder on it reveals that sklearn/base.py (the base estimator class) has a blast radius of 847 files: changing BaseEstimator or TransformerMixin could potentially affect 71% of the codebase. However, the impact set for a specific change (say, renaming the get_params method) is only 156 files, because not every file in the blast radius actually calls get_params. This roughly 5x reduction from blast radius to impact set is typical for well-factored codebases with clear interface boundaries. Poorly factored codebases, where every module reaches into the internals of every other module, show a much smaller reduction (sometimes only 1.2x), which is itself a useful code quality metric.

3. Automated Patch Review

Impact analysis tells us where to look; patch review determines whether the change is correct. The three-layer pipeline below catches different classes of defects at each stage, as illustrated in Figure 16.3: Figure 16.2.1 illustrates three-layer patch review pipeline with blast radius filtering.

Three-layer patch review pipeline with blast radius filtering
Figure 16.2.1: The three-layer patch review pipeline. A code patch is first filtered through blast radius and impact set analysis to identify affected files, then passes through structural, dynamic, and semantic review layers, each gating on severity. The composite quality score drives a feedback loop that retries generation with review findings until the threshold is met.
Generated Patch Layer 1 Structural Syntax, imports < 1 second Fail fast Layer 2 Dynamic Targeted tests seconds to min Layer 3 Semantic LLM review seconds (API) Findings + Score (Q >= 0.85 to merge) Increasing cost per layer; each stage filters before the next runs
Figure 16.3: Three-layer patch review pipeline. Structural checks run first and fail fast on syntax or import errors. Dynamic review runs only the tests in the impact set. Semantic review uses an LLM to catch logical errors that survive the first two layers. A composite quality score determines whether the patch may merge.
  1. Structural review: static checks that do not require running the code. Import resolution, type annotation consistency, naming convention compliance.
  2. Dynamic review: running the test suite (or a targeted subset) to catch behavioral regressions.
  3. Semantic review: using an LLM to evaluate whether the change correctly implements the intended behavior, catching logical errors that pass both structural and dynamic checks.

Each layer is progressively more expensive and catches progressively subtler bugs. Structural review runs in milliseconds, dynamic review in seconds to minutes, and semantic review in seconds (bounded by LLM latency). The layered approach lets us fail fast on obvious errors without wasting compute on deeper analysis.

from dataclasses import dataclass
from enum import Enum
from typing import Any

class ReviewSeverity(Enum):
    ERROR = "error"       # blocks merge
    WARNING = "warning"   # requires human review
    INFO = "info"         # informational only

@dataclass
class ReviewFinding:
    """A single finding from the review pipeline."""
    layer: str           # "structural", "dynamic", or "semantic"
    severity: ReviewSeverity
    file_path: str
    line: int | None
    message: str
    suggestion: str | None = None

class PatchReviewer:
    """Three-layer automated patch review pipeline."""

    def __init__(self, repo_root: str, dep_graph: DependencyGraph):
        self.repo_root = Path(repo_root)
        self.dep_graph = dep_graph

    def review(
        self, patch: dict[str, str], changed_symbols: dict[str, set[str]]
    ) -> list[ReviewFinding]:
        """Run all review layers on a patch.

        Args:
            patch: {file_path: new_content} for each changed file
            changed_symbols: {file_path: {symbol_names}} that were modified

        The pipeline short-circuits (data-agent 03): if Layer 1
        (structural) finds any ERROR-severity issue, Layers 2 and 3
        do not run, because a patch with syntax errors cannot pass
Try It: Build a Blast Radius Visualizer
Try It: Build a Blast Radius Visualizer
tests or benefit from semantic review. """ findings = [] # Layer 1: Structural review (fast, always runs) findings.extend(self._structural_review(patch)) # Stop early if structural errors found errors = [f for f in findings if f.severity == ReviewSeverity.ERROR] if errors: return findings # Layer 2: Dynamic review (run affected tests) findings.extend( self._dynamic_review(patch, changed_symbols) ) # Layer 3: Semantic review (LLM-based) findings.extend(self._semantic_review(patch)) return findings def _structural_review( self, patch: dict[str, str] ) -> list[ReviewFinding]: """Check import resolution and type consistency.""" findings = [] for path, content in patch.items(): # Check 1: all imports resolve try: tree = parser.parse(content.encode("utf-8")) except Exception as e: findings.append(ReviewFinding( layer="structural", severity=ReviewSeverity.ERROR, file_path=path, line=None, message=f"Parse error: {e}", )) continue # Check 2: no syntax errors in the parse tree if tree.root_node.has_error: # Find the error node error_nodes = self._find_error_nodes(tree.root_node) for node in error_nodes: findings.append(ReviewFinding( layer="structural", severity=ReviewSeverity.ERROR, file_path=path, line=node.start_point[0] + 1, message=( f"Syntax error at line " f"{node.start_point[0] + 1}, " f"column {node.start_point[1]}" ), )) # Check 3: no duplicate function definitions defs = self._extract_all_defs(tree.root_node) seen: dict[str, int] = {} for name, line in defs: if name in seen: findings.append(ReviewFinding( layer="structural", severity=ReviewSeverity.WARNING, file_path=path, line=line, message=( f"Duplicate definition of '{name}' " f"(first defined at line {seen[name]})" ), suggestion=( f"Remove duplicate or rename one of the " f"'{name}' definitions" ), )) seen[name] = line return findings def _dynamic_review( self, patch: dict[str, str], changed_symbols: dict[str, set[str]] ) -> list[ReviewFinding]: """Run affected tests and report failures.""" findings = [] # Identify affected test files via impact analysis test_files: set[str] = set() for path, symbols in changed_symbols.items(): impact = self.dep_graph.impact_set(path, symbols) for affected_file in impact: if "test" in affected_file: test_files.add(affected_file) if not test_files: findings.append(ReviewFinding( layer="dynamic", severity=ReviewSeverity.WARNING, file_path="(none)", line=None, message="No test files in the impact set", suggestion="Add tests that cover the changed code", )) return findings # Run pytest on affected test files import subprocess test_paths = " ".join(str(self.repo_root / t) for t in test_files) result = subprocess.run( f"python -m pytest {test_paths} --tb=short -q", shell=True, capture_output=True, text=True, cwd=self.repo_root, timeout=300, ) if result.returncode != 0: # Parse pytest output for individual failures for line in result.stdout.splitlines(): if "FAILED" in line: parts = line.split("::") test_file = parts[0].strip() if parts else "(unknown)" findings.append(ReviewFinding( layer="dynamic", severity=ReviewSeverity.ERROR, file_path=test_file, line=None, message=f"Test failure: {line.strip()}", )) return findings def _semantic_review( self, patch: dict[str, str] ) -> list[ReviewFinding]: """Use an LLM to review the patch for logical correctness.""" findings = [] # Build a unified diff representation diff_parts = [] for path, content in patch.items(): original_path = self.repo_root / path if original_path.exists(): original = original_path.read_text(encoding="utf-8") diff_parts.append( f"--- {path} (original)\n" f"+++ {path} (modified)\n" f"Original:\n{original[:2000]}\n" f"Modified:\n{content[:2000]}" ) else: diff_parts.append( f"+++ {path} (new file)\n{content[:2000]}" ) prompt = f"""Review this code patch for logical correctness. Focus on: 1. Does the change correctly implement its apparent intent? 2. Are there edge cases that are not handled? 3. Are error handling paths complete? 4. Are there any security concerns? Respond in JSON: [{{"file": "...", "line": N, "severity": "warning|info", "message": "...", "suggestion": "..."}}] Return an empty list [] if no issues found. Patch: {chr(10).join(diff_parts)}""" import subprocess, json result = subprocess.run( ["claude", "--print", "--model", "claude-sonnet-4-20250514", "--max-tokens", "2048", "-p", prompt], capture_output=True, text=True, cwd=self.repo_root, ) try: review_items = json.loads(result.stdout) for item in review_items: findings.append(ReviewFinding( layer="semantic", severity=ReviewSeverity( item.get("severity", "info") ), file_path=item.get("file", "(unknown)"), line=item.get("line"), message=item.get("message", ""), suggestion=item.get("suggestion"), )) except (json.JSONDecodeError, ValueError): pass # LLM response was not valid JSON; skip semantic layer return findings def _find_error_nodes(self, node) -> list: """Recursively find ERROR nodes in the syntax tree.""" errors = [] if node.type == "ERROR": errors.append(node) for child in node.children: errors.extend(self._find_error_nodes(child)) return errors def _extract_all_defs(self, root_node) -> list[tuple[str, int]]: """Extract all function/class definition names and lines.""" defs = [] for child in root_node.children: if child.type in ("function_definition", "class_definition"): name_node = child.child_by_field_name("name") if name_node: defs.append(( name_node.text.decode("utf-8"), name_node.start_point[0] + 1, )) return defs
Listing 16.9: Three-layer PatchReviewer with structural checks (tree-sitter parse errors and duplicate definitions), dynamic testing (pytest on impact-set test files), and LLM-based semantic review.

The layered architecture reflects a principle from the system architecture chapter (Chapter 6): place cheap, fast checks at the outer layer and expensive, slow checks at the inner layer. In our experience, structural review catches roughly 40-60% of AI-generated patch defects (mostly syntax errors and import issues) at near-zero cost. Dynamic review catches another 20-30% (behavioral regressions). Semantic review catches most of the remainder (logical errors, missed edge cases), but at the cost of an additional LLM call.

Key Insight: Selective Test Execution

Running the entire test suite after every generated patch is wasteful. The impact set tells us exactly which test files are affected by a change, enabling selective test execution. For a repository with 2,000 test files, changing a utility function might affect only 15 tests. Running those 15 tests takes 3 seconds; running all 2,000 takes 5 minutes. Over a generation pipeline that produces 10 candidate patches, selective execution saves 49 minutes of wall-clock time. This is the same principle behind test selection in Chapter 18, applied here as part of the review pipeline rather than the testing workflow.

4. Scoring Patch Quality

Individual findings are useful for developers reading a review. For automated pipelines that need to decide whether to merge, retry, or reject a patch, we need a single quality score. We define a composite patch quality score that weights findings by severity and normalizes by patch size:

$$ Q(\text{patch}) = 1 - \frac{\sum_{f \in \text{findings}} w(f.\text{severity})}{|\text{patch\_files}| \cdot W_{\max}} $$

where \(w(\text{error}) = 1.0\), \(w(\text{warning}) = 0.3\), \(w(\text{info}) = 0.05\), and \(W_{\max}\) is a normalization constant (typically 3.0, representing the maximum expected weighted findings per file). A score of 1.0 means no findings; a score below 0.7 typically indicates the patch should not be merged without human review.

Checkpoint

So far: dependency graphs give us a blast radius (all files that could break), symbol-level filtering narrows that to an impact set (files that reference changed symbols), the three-layer review pipeline (structural, dynamic, semantic) catches defects at increasing cost, and now a composite quality score collapses all findings into a single merge-or-reject number.

def score_patch(findings: list[ReviewFinding], num_files: int) -> float:
    """Compute a composite quality score for a reviewed patch.

    Returns a float in [0, 1] where 1.0 = no issues found.
    """
    severity_weights = {
        ReviewSeverity.ERROR: 1.0,
        ReviewSeverity.WARNING: 0.3,
        ReviewSeverity.INFO: 0.05,
    }
    w_max = 3.0  # normalization: max expected weighted findings per file

    total_weight = sum(
        severity_weights.get(f.severity, 0) for f in findings
    )

    denominator = max(num_files, 1) * w_max
    score = 1.0 - (total_weight / denominator)
    return max(0.0, min(1.0, score))  # clamp to [0, 1]

# Example
findings = [
    ReviewFinding("structural", ReviewSeverity.WARNING,
                  "api/views.py", 42, "Unused import"),
    ReviewFinding("dynamic", ReviewSeverity.ERROR,
                  "tests/test_api.py", None, "Test failure"),
    ReviewFinding("semantic", ReviewSeverity.INFO,
                  "models/user.py", 15, "Consider adding docstring"),
]
score = score_patch(findings, num_files=5)
print(f"Patch quality score: {score:.2f}")
# Output: Patch quality score: 0.91
Listing 16.10: Composite quality scoring function that weights ERROR, WARNING, and INFO findings differently and normalizes by the number of changed files.

5. The Review Feedback Loop

A quality score is only useful if the system can act on it; when a patch falls below the threshold, the next step is to feed the findings back to the generator and try again.

A single review pass catches defects; a feedback loop fixes them. The pattern is: generate a patch, review it, feed the findings back to the generator as additional context, and re-generate. This loop tends to converge quickly: in our experiments, around 80% of structural findings are resolved on the first retry, and roughly 95% within two retries. Semantic findings are harder to resolve automatically (the model may disagree with the reviewer or may not understand the suggestion), so we cap the loop at three iterations and escalate remaining findings to human review.

def generate_and_review_loop(
    generator: "MultiFileGenerator",  # from Section 16.1
    reviewer: PatchReviewer,
    task: str,
    max_iterations: int = 3,
    quality_threshold: float = 0.85,
) -> tuple[dict[str, str], list[ReviewFinding], float]:
    """Generate a patch, review it, and iterate until quality threshold.

    Returns: (final_patch, remaining_findings, quality_score)
    """
    patch = None
    findings = []
    score = 0.0

    for iteration in range(max_iterations):
        # Generate (first iteration) or re-generate (subsequent)
        if iteration == 0:
            patch = generator.execute(task)
        else:
            # Feed findings back as additional context
            feedback = format_findings_as_feedback(findings)
            augmented_task = (
                f"{task}\n\n"
                f"Previous attempt had these review findings:\n"
                f"{feedback}\n"
                f"Please address all ERROR and WARNING findings."
            )
            generator.generated_edits.clear()
            patch = generator.execute(augmented_task)

        # Collect changed symbols for impact analysis
        changed_symbols: dict[str, set[str]] = {}
        for path, content in patch.items():
            # extract_definitions uses tree-sitter (as in Listing 16.8)
            # to return top-level function and class names from source text
            defs = extract_definitions(content)
            changed_symbols[path] = {d["name"] for d in defs}

        # Review
        findings = reviewer.review(patch, changed_symbols)
        score = score_patch(findings, len(patch))

        print(f"Iteration {iteration + 1}: "
              f"score={score:.2f}, "
              f"errors={sum(1 for f in findings if f.severity == ReviewSeverity.ERROR)}, "
              f"warnings={sum(1 for f in findings if f.severity == ReviewSeverity.WARNING)}")

        if score >= quality_threshold:
            print(f"Quality threshold met at iteration {iteration + 1}")
            break

    return patch, findings, score


def format_findings_as_feedback(findings: list[ReviewFinding]) -> str:
    """Format review findings as actionable feedback for the generator."""
    lines = []
    for f in findings:
        if f.severity in (ReviewSeverity.ERROR, ReviewSeverity.WARNING):
            loc = f"line {f.line}" if f.line else "unknown location"
            entry = f"[{f.severity.value}] {f.file_path} ({loc}): {f.message}"
            if f.suggestion:
                entry += f"\n  Suggestion: {f.suggestion}"
            lines.append(entry)
    return "\n".join(lines)
Listing 16.11: Generate-review-retry loop that feeds ERROR and WARNING findings back to the generator as context for re-generation, capped at three iterations.
Fun Note: When the Reviewer and Generator Disagree

In a memorable debugging session, a semantic reviewer flagged a generated function as "missing error handling for None input." The generator, on retry, added a None check. The reviewer then flagged the check as "dead code, since the caller guarantees non-None." The generator removed it. The reviewer flagged it again. Three iterations later, the function contained a None check inside a comment: # This check is here because the reviewer insists, but it will never trigger. This passive-aggressive commit message is a reminder that LLM-based review and LLM-based generation can enter adversarial loops. The iteration cap exists precisely for this reason.

6. Integrating with the Discovery Workbench

The impact analysis and review components join the Discovery Workbench's change orchestration module, first introduced in Chapter 6. The Workbench exposes two new services: an ImpactAnalyzer that any agent can query to assess a proposed change's consequences, and a PatchReviewer that validates generated patches before they enter the merge queue. The multi-agent teams of Chapter 17 consume both services: a dedicated reviewer agent calls the PatchReviewer API to evaluate patches that coder agents produce.

class ChangeOrchestrator:
    """Discovery Workbench module for coordinated code changes."""

    def __init__(self, repo_root: str):
        self.repo_root = repo_root
        self.dep_graph = build_symbol_index(repo_root)
        self.reviewer = PatchReviewer(repo_root, self.dep_graph)

    def analyze_impact(
        self, file_path: str, symbols: set[str]
    ) -> dict:
        """Query the impact of changing specific symbols."""
        blast = self.dep_graph.blast_radius(file_path)
        impact = self.dep_graph.impact_set(file_path, symbols)
        report = self.dep_graph.impact_report(file_path, symbols)

        return {
            "blast_radius_size": len(blast),
            "impact_set_size": len(impact),
            "affected_files": list(impact.keys()),
            "report": report,
        }

    def review_patch(
        self, patch: dict[str, str],
        changed_symbols: dict[str, set[str]]
    ) -> dict:
        """Review a patch and return structured results."""
        findings = self.reviewer.review(patch, changed_symbols)
        score = score_patch(findings, len(patch))

        return {
            "score": score,
            "pass": score >= 0.85,
            "findings": [
                {
                    "layer": f.layer,
                    "severity": f.severity.value,
                    "file": f.file_path,
                    "line": f.line,
                    "message": f.message,
                    "suggestion": f.suggestion,
                }
                for f in findings
            ],
        }
Listing 16.12: ChangeOrchestrator that wraps DependencyGraph and PatchReviewer into a single Workbench service with JSON-serializable output for agent consumption.

Research Frontier

Google's CodeReview agent, described in "AI-Assisted Code Authoring" (Liang et al., 2024), deployed an LLM-based reviewer across Google's monorepo that processes thousands of changelists per day. The system goes beyond the static severity weights used in this section by incorporating repository-specific context: it retrieves similar past reviews from a vector database (a storage system optimized for similarity search over high-dimensional embeddings) and calibrates its confidence based on how often its suggestions were accepted versus dismissed by human reviewers. Over a six-month deployment, the system achieved a 52% acceptance rate on its comments (up from 38% at launch), with particularly strong performance on detecting cross-file inconsistencies in API contract changes. This line of work suggests that the fixed-threshold review pipeline presented here will increasingly be replaced by adaptive systems that learn reviewer calibration from project-specific feedback loops.

Library Shortcut: Semgrep for Structural Review

Semgrep is an open-source static analysis tool that supports custom rules written in YAML. A single pip install semgrep and a rules file replaces most of the structural review layer from Listing 16.9. Semgrep can check for unused imports, type annotation issues, security patterns, and custom project-specific invariants. It processes a 100,000-line Python codebase in under 10 seconds. What takes roughly 100 lines of tree-sitter code to implement in our structural reviewer, Semgrep handles with a 10-line YAML rule. For production patch review pipelines, combining Semgrep for structural checks with our custom impact analysis (which Semgrep does not provide) gives the best of both worlds.

Try It: Build a Blast Radius Visualizer

Build a dependency graph and impact analyzer for a real Python project in five steps, using only the standard library plus pip install tree-sitter tree-sitter-python.

  1. Clone a medium-sized open-source Python project (Flask, Requests, or FastAPI work well). Run find . -name "*.py" | wc -l to confirm it has at least 50 Python files.
  2. Copy the DependencyGraph class and build_symbol_index function from Listings 16.7 and 16.8 into a script called impact_tool.py. Run it on the cloned project to build the graph.
  3. Pick three files with varying import popularity (one heavily imported, one moderately imported, one leaf module). For each, call blast_radius() and record the count. Then call impact_set() with one symbol from each file and record those counts.
  4. Compute the selectivity ratio (impact set size / blast radius size) for each file. Print a table comparing the three files. Verify that leaf modules have a blast radius near zero while core modules have large blast radii but smaller selectivity ratios.
  5. Generate a simple DOT-format graph (the plain-text graph description language used by Graphviz) of the top-20 most-depended-upon files and their direct dependents. Render it with dot -Tpng graph.dot -o graph.png (install Graphviz if needed) and inspect which modules are structural bottlenecks.

Lab: Measure Impact Set Selectivity Across Project History

Goal: Quantify how well symbol-level filtering reduces review burden compared to raw blast radius, using real commit data from an open-source project.
Tools needed: Python 3.10+, pip install tree-sitter tree-sitter-python gitpython matplotlib.
Procedure (20 minutes): Clone a medium-sized Python project (Flask or Requests). For each of the 20 most recent commits that modify .py files, use GitPython to extract which files and symbols changed. Build the dependency graph with build_symbol_index() from Listing 16.8, then compute both blast_radius() and impact_set() for every changed file. Record the selectivity ratio (impact set size / blast radius size) per commit.
What to vary: Try the same analysis on a tightly coupled project (a Django monolith) versus a loosely coupled one (a microservices repo with small packages). Compare the distribution of selectivity ratios.
What to observe: Plot selectivity ratio versus blast radius size as a scatter chart. Well-factored projects should cluster in the lower-left (small blast radii, low selectivity ratios). Tightly coupled projects will show points in the upper-right (large blast radii, selectivity ratios approaching 1.0, meaning the impact set is nearly as large as the blast radius and symbol-level filtering provides little benefit).

Exercises

  1. (Conceptual) A repository has 500 files. Changing one base class produces a blast radius of 200 files and an impact set of 30 files. Calculate the selectivity ratio (impact set / blast radius). Explain two architectural patterns that would increase this ratio (making changes safer) and two that would decrease it (making changes riskier).
  2. (Coding) Extend the PatchReviewer from Listing 16.9 to detect orphaned references: symbols that existed before the patch but are removed or renamed in the patch, while references to the old name persist in files outside the patch. Test your detector on a synthetic example where a function is renamed in one file but three call sites in other files still use the old name.
  3. (Analysis) Run the build_symbol_index function from Listing 16.8 on a Python project and compute the blast radius for the five most-imported modules. Plot blast radius versus in-degree. Is the relationship linear, sublinear, or superlinear? What does this tell you about the project's module coupling structure?

What's Next

We have built the analysis and review infrastructure that evaluates multi-file patches. In Section 16.3: Building a Repository-Scale Change, we assemble all the pieces into a complete end-to-end workflow: taking a task specification, generating a coordinated multi-file patch against a real open-source repository, running impact analysis, validating with tests, and packaging the result as a pull request ready for human review.