# What If Your Pull Request Had a Blast Radius Score?

Some pull requests look harmless until they reach production. Then someone notices the change touched authentication, a shared response contract, an EF Core migration, and one production config value that doesn't exist in staging. That wasn't a small PR. That was a blast radius problem. We tend to review pull requests through the shape GitHub gives us. Files changed. Lines added. Lines removed. Comments. Checks. Approvals.

Those signals help, but they're incomplete. A one thousand line test only change may be tedious. A twelve line change to token validation may deserve every senior engineer in the room. A tiny update to `appsettings.Production.json` might be the whole incident.

So I started thinking about a small tool I wish existed in more .NET teams. What if every pull request had a **Blast Radius Score**? Not as a gate. Not as a replacement for review. More like a warning label that says, "look here first".

```text
Blast Radius Score: 78 / 100
Risk level: High

Main signals:
- Public response contract changed
- EF Core migration added
- Production configuration changed
- Authorisation code touched
- No matching tests changed
```

That score doesn't need to be perfect to be useful. It just needs to make risk visible before the first reviewer opens the diff.

## PR size is a weak signal

A lot of review conversations still start with size.

"This PR is only 80 lines."

"This PR is too big."

"Can you split this up?"

Size is important, but size alone can be misleading. Some large pull requests are mostly mechanical. Some tiny ones change the behaviour of the whole system. In a .NET application, the dangerous changes are often the ones that cross a boundary. A handler implementation change may stay local. A DTO change can leak into consumers. A migration can affect existing data. A config key can behave differently in every environment. A change to `AddAuthorization` can alter access rules across an entire API. Thats the kind of difference a blast radius score should surface.

The useful question is simple:

> How far could this change spread if it's wrong?

That framing changes the review. Instead of asking someone to stare harder at a diff, the tool points them towards the parts of the change that deserve attention.

## What blast radius means in a .NET app

In a .NET codebase, blast radius usually comes from boundaries and runtime behaviour. A private method inside one feature has a small radius. It might still contain a bug, but the damage is likely contained. A public API response has a wider radius. Other services, clients, tests, dashboards, and support scripts may depend on it. A database migration has a different kind of radius. It changes the shape of persisted state. That makes rollback harder, especially when production data starts moving through the new shape.

Configuration is another classic source of risk. A change can pass locally because local config is complete. It can pass in test because test has a fallback. Then production fails because a key was named differently in one deployment slot.

Security sensitive code deserves special weight. Authentication, authorisation, claims mapping, CORS, token validation, and secret handling can be affected by tiny changes. They shouldn't be reviewed with the same level of suspicion as a formatting fix. Runtime behaviour is important too. Retries, timeouts, background workers, queue consumers, caching, and `HttpClient` setup can change how a system behaves under pressure. Those changes may not look dramatic in code review, but they decide what happens at 2am when a dependency starts timing out.

A good blast radius tool should know about those areas.

## The first version should be simple on purpose

The tempting version of this idea is too clever. You could imagine AI reading the whole pull request and writing a risk assessment. You could imagine deep semantic analysis. You could imagine a model trained on historic incidents.

I wouldn't start there.

The first useful version can be rule based. Look at the changed files. Classify them. Apply simple weights. Produce a short report. Make the output simple enough that people trust it. The tool could run as a .NET global tool, a GitHub Action, or both.

```bash
dotnet tool install --global BlastGuard.Cli

blastguard analyse --base main --head feature/payment-change
```

The output might look like this:

```text
Blast Radius Report

Score: 82 / 100
Risk: High

Detected risk areas:

Public contracts
- Modified CreatePaymentRequest
- Modified PaymentStatusResponse

Database
- Added EF Core migration
- Changed payment amount precision

Configuration
- Modified appsettings.Production.json
- Added Payments:ProviderTimeoutSeconds

Security-sensitive code
- Modified ClaimsPrincipalExtensions
- Modified authorisation policy registration

Test signal
- No matching test files changed under Payments.Tests

Suggested review focus:
- Confirm API consumers can handle the response change
- Confirm the migration is backwards compatible
- Confirm production config exists in all environments
- Add tests around authorisation and amount precision
```

That report is deliberately plain. The value comes from focus. It gives reviewers a starting point.

## A simple scoring pipeline

The scoring flow can be simple enough to explain in one diagram.

![](https://cdn.hashnode.com/uploads/covers/67c36038c69a4b7143c5fc49/ce0387e6-56e5-4471-8e73-210fe85abec0.png align="center")

At the centre is a change set.

```csharp
public sealed record PullRequestChangeSet(
    string BaseRef,
    string HeadRef,
    IReadOnlyList<ChangedFile> Files);

public sealed record ChangedFile(
    string Path,
    ChangeKind ChangeKind,
    int Additions,
    int Deletions,
    string? Patch);

public enum ChangeKind
{
    Added,
    Modified,
    Deleted,
    Renamed
}
```

The first pass doesn't need to parse every line of C#. It can get useful signals from paths and filenames.

```csharp
public enum ChangedFileType
{
    Unknown,
    Documentation,
    Test,
    ApplicationCode,
    PublicContract,
    DatabaseMigration,
    Configuration,
    ProductionConfiguration,
    SecuritySensitive,
    RuntimeBehaviour,
    Infrastructure
}
```

A classifier can start with conventions.

```csharp
public sealed class ChangedFileClassifier
{
    public ChangedFileType Classify(string path)
    {
        if (path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Documentation;

        if (path.Contains(".Tests/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains(".Tests\\", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Test;

        if (path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("\\Migrations\\", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.DatabaseMigration;

        if (path.EndsWith("appsettings.Production.json", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.ProductionConfiguration;

        if (path.EndsWith("appsettings.json", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Configuration;

        if (path.Contains("/Contracts/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/Dtos/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/Messages/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.PublicContract;

        if (path.Contains("/Auth/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/Security/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.SecuritySensitive;

        if (path.Contains("/Workers/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/HostedServices/", StringComparison.OrdinalIgnoreCase) ||
            path.Contains("/HttpClients/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.RuntimeBehaviour;

        if (path.Contains("/Infrastructure/", StringComparison.OrdinalIgnoreCase))
            return ChangedFileType.Infrastructure;

        return ChangedFileType.ApplicationCode;
    }
}
```

This kind of code won't catch everything. That's fine. Early value beats perfect detection. The tool should also let teams configure their own rules, because every codebase has different risk areas.

```json
{
  "riskAreas": [
    {
      "name": "Payments",
      "paths": ["src/Payments/**"],
      "points": 15
    },
    {
      "name": "Authentication",
      "paths": ["src/**/Auth/**", "src/**/Security/**"],
      "points": 25
    },
    {
      "name": "Public Contracts",
      "paths": ["src/**/Contracts/**", "src/**/Dtos/**"],
      "points": 20
    }
  ]
}
```

The default rules get you started. The config makes it fit your architecture.

## The scoring model

I would keep the scoring model obvious. The tool adds points for detected risk signals. It subtracts points for safer signals. Then it clamps the result between 0 and 100.

| Signal | Points |
| --- | --- |
| Public API contract changed | +20 |
| Message contract changed | +20 |
| EF Core migration added | +20 |
| Production config changed | +15 |
| Authentication code touched | +25 |
| Authorisation code touched | +25 |
| Background worker changed | +15 |
| Retry or timeout policy changed | +10 |
| Multiple bounded areas touched | +5 each |
| No matching tests changed | +10 |
| Large PR over 500 changed lines | +10 |
| Large PR over 1,500 changed lines | +20 |
| Only documentation changed | \-30 |
| Only tests changed | \-20 |

The exact numbers aren't sacred. They exist to start a conversation. A basic domain model:

```csharp
public sealed record BlastRadiusScore(
    int Value,
    RiskLevel RiskLevel,
    IReadOnlyList<RiskFinding> Findings);

public sealed record RiskFinding(
    string Category,
    int Points,
    string Message,
    string? FilePath = null);

public enum RiskLevel
{
    Low,
    Medium,
    High,
    Critical
}
```

The scoring engine can be tiny.

```csharp
public interface IBlastRadiusRule
{
    IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet);
}

public sealed class BlastRadiusScorer(IEnumerable<IBlastRadiusRule> rules)
{
    public BlastRadiusScore Score(PullRequestChangeSet changeSet)
    {
        var findings = rules
            .SelectMany(rule => rule.Analyse(changeSet))
            .ToList();

        var rawScore = findings.Sum(x => x.Points);
        var score = Math.Clamp(rawScore, 0, 100);

        return new BlastRadiusScore(
            Value: score,
            RiskLevel: ToRiskLevel(score),
            Findings: findings);
    }

    private static RiskLevel ToRiskLevel(int score) =>
        score switch
        {
            >= 75 => RiskLevel.Critical,
            >= 50 => RiskLevel.High,
            >= 25 => RiskLevel.Medium,
            _ => RiskLevel.Low
        };
}
```

The rules do the actual work.

```csharp
public sealed class EfMigrationRule : IBlastRadiusRule
{
    public IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet)
    {
        foreach (var file in changeSet.Files)
        {
            var isMigration = file.Path.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase) ||
                              file.Path.Contains("\\Migrations\\", StringComparison.OrdinalIgnoreCase);

            if (!isMigration)
                continue;

            yield return new RiskFinding(
                Category: "Database",
                Points: 20,
                Message: "EF Core migration changed",
                FilePath: file.Path);
        }
    }
}
```

A production configuration rule is just as simple.

```csharp
public sealed class ProductionConfigurationRule : IBlastRadiusRule
{
    public IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet)
    {
        foreach (var file in changeSet.Files)
        {
            if (!file.Path.EndsWith("appsettings.Production.json", StringComparison.OrdinalIgnoreCase))
                continue;

            yield return new RiskFinding(
                Category: "Configuration",
                Points: 15,
                Message: "Production configuration changed",
                FilePath: file.Path);
        }
    }
}
```

That already gives you something useful.

## Making it understand .NET better

Path-based rules are a good start, but .NET gives us richer clues. A blast radius tool can look for common framework patterns. It doesn't need to understand the whole application. It just needs to recognise shapes that often carry risk. For Minimal APIs, route changes are important. A tool could inspect changed lines for calls like `MapGet`, `MapPost`, `MapPut`, and `MapDelete`. If a route changes, the report should mention it.

```csharp
app.MapPost("/api/payments", async (
    CreatePaymentRequest request,
    CreatePaymentHandler handler,
    CancellationToken cancellationToken) =>
{
    var result = await handler.Handle(request, cancellationToken);
    return result.ToHttpResult();
});
```

For EF Core, migrations are the obvious signal, but model configuration can be just as important. A change to `IEntityTypeConfiguration<T>` can alter column sizes, indexes, relationships, and delete behaviour.

```csharp
public sealed class PaymentConfiguration : IEntityTypeConfiguration<Payment>
{
    public void Configure(EntityTypeBuilder<Payment> builder)
    {
        builder.Property(x => x.Amount)
            .HasPrecision(19, 4);
    }
}
```

A precision change like that might be correct. It still deserves a focused review because it affects persisted data and financial calculations. For options classes, the tool can flag config binding changes. These often look harmless, but they can break at runtime if the environment is missing a value.

```csharp
builder.Services
    .AddOptions<PaymentProviderOptions>()
    .BindConfiguration("Payments:Provider")
    .ValidateDataAnnotations()
    .ValidateOnStart();
```

A nice rule would reward `ValidateOnStart`. If production config changes and options validation is missing, the score should rise. If the options are validated on startup, the score can be softened. For hosted services, queue consumers, and background workers, the tool should assume wider runtime impact. These areas often handle retries, concurrency, poison messages, and long running work.

```csharp
public sealed class PaymentStatusWorker : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            await ProcessPendingPayments(stoppingToken);
        }
    }
}
```

Changes here can pass unit tests and still behave badly under load.

For outbound dependencies, the tool should care about `HttpClient`, retry policies, timeouts, and circuit breakers.

```csharp
builder.Services
    .AddHttpClient<PaymentProviderClient>()
    .ConfigureHttpClient(client =>
    {
        client.Timeout = TimeSpan.FromSeconds(10);
    });
```

A timeout change from 10 seconds to 90 seconds is small in the diff. It can be huge in production. For authentication and authorisation, the score should rise quickly. The changed line count barely matters here.

```csharp
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("CanApprovePayment", policy =>
    {
        policy.RequireClaim("permission", "payments.approve");
    });
});
```

One altered claim name can lock people out or let the wrong people in.

## Test changes should affect the score

A blast radius score should look at the risk and the evidence around that risk. If a PR changes a public contract and also updates contract tests, that is still a risk signal. It just feels better than changing the contract with no matching tests. The first version can use a rough convention.

If files under `src/Payments` changed, look for changed files under `tests/Payments.Tests`. If authentication code changed, look for tests with `Auth`, `Authorization`, or `Claims` in the path. If a migration changed, look for integration tests or migration tests.

```csharp
public sealed class MissingMatchingTestsRule : IBlastRadiusRule
{
    public IEnumerable<RiskFinding> Analyse(PullRequestChangeSet changeSet)
    {
        var applicationAreas = changeSet.Files
            .Where(file => file.Path.StartsWith("src/", StringComparison.OrdinalIgnoreCase))
            .Select(file => GetTopLevelArea(file.Path))
            .Where(area => area is not null)
            .Distinct(StringComparer.OrdinalIgnoreCase)
            .ToList();

        var testPaths = changeSet.Files
            .Where(file => file.Path.Contains(".Tests", StringComparison.OrdinalIgnoreCase))
            .Select(file => file.Path)
            .ToList();

        foreach (var area in applicationAreas)
        {
            var hasMatchingTests = testPaths.Any(path =>
                path.Contains(area!, StringComparison.OrdinalIgnoreCase));

            if (hasMatchingTests)
                continue;

            yield return new RiskFinding(
                Category: "Tests",
                Points: 10,
                Message: $"No matching test changes found for {area}");
        }
    }

    private static string? GetTopLevelArea(string path)
    {
        var parts = path.Split(['/', '\\'], StringSplitOptions.RemoveEmptyEntries);
        return parts.Length >= 2 ? parts[1] : null;
    }
}
```

This is imperfect, but useful. It won't know whether the tests are good. It will spot when no related tests moved at all. That alone catches a lot of risky PRs.

## The PR comment

Bad automation creates noise. Good automation helps the reviewer make a better decision. The PR comment should be short, specific, and calm. It shouldn't sound like a security scanner screaming about everything.

```markdown
## Blast Radius Score: 76 / 100

Risk level: High

This PR touches areas that can affect runtime behaviour outside the changed files.

### Main risk signals

| Area | Finding | Points |
|---|---:|---:|
| API Contract | Response DTO changed | +20 |
| Database | EF Core migration added | +20 |
| Configuration | Production settings changed | +15 |
| Tests | No matching test changes found | +10 |
| Scope | 3 bounded areas touched | +15 |

### Suggested review focus

- Check whether API consumers are affected.
- Check whether the migration is backwards compatible.
- Confirm the new config value exists in each environment.
- Add or update tests around the changed contract.
```

The phrase "suggested review focus" is important. This shouldn't tell the reviewer what to think. It should tell them where to look.

## Running it in GitHub Actions

The most useful place for this tool is probably the pull request itself. A GitHub Action could run on every PR, score the change, then publish a check summary or comment.

```yaml
name: Blast Radius

on:
  pull_request:
    branches:
      - main

jobs:
  blast-radius:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Install BlastGuard
        run: dotnet tool install --global BlastGuard.Cli

      - name: Analyse pull request
        run: |
          blastguard analyse \
            --base origin/${{ github.base_ref }} \
            --head HEAD \
            --format github
```

The first implementation doesn't need to block the PR. I would start with advisory comments only. Once the team trusts the signal, you can add optional thresholds. For example, a critical score could require an architecture review, a database review, or a second senior reviewer. That step should come later. If you make the tool a hard gate too early, people will treat it as an obstacle rather than a useful review aid.

## What different scores might look like

A low score should be simple.

```text
Score: 8 / 100
Risk: Low

Changed:
- README.md
- One unit test file

Review focus:
- Normal review only
```

A medium score should give context without being dramatic.

```text
Score: 42 / 100
Risk: Medium

Changed:
- One feature handler
- One validator
- Matching tests

Review focus:
- Check validation behaviour
- Check handler branching
```

A high score should make the reviewer slow down.

```text
Score: 71 / 100
Risk: High

Changed:
- API response contract
- Mapping logic
- EF Core migration
- No contract tests detected

Review focus:
- Check API compatibility
- Check migration safety
- Check missing tests
```

A critical score should be rare. If everything is critical, nothing is.

```text
Score: 94 / 100
Risk: Critical

Changed:
- Auth policy registration
- Claims mapping
- Production config
- Shared package version
- No tests changed

Review focus:
- Confirm access rules
- Confirm production config
- Confirm downstream impact
- Consider splitting the PR
```

The tool earns trust by staying quiet on ordinary changes.

## Where this helps

This kind of tool helps most when reviewers are busy. Reviewers don't always know the whole codebase. A backend engineer may not immediately know that a DTO is consumed by a frontend app. A new team member may not know that `ClaimsPrincipalExtensions` sits on the hot path for every secured endpoint. A senior engineer may be reviewing quickly between meetings.

The blast radius report gives them a map of likely risk. It also helps with PR descriptions. If the author says "small refactor" but the tool spots a migration and production config change, the conversation changes. The reviewer can ask for a better description, a rollout note, or extra tests. It helps team leads too. A dashboard of recent high risk PRs could reveal patterns. Maybe risky changes often arrive late in the sprint. Maybe migrations are regularly merged without tests. Maybe auth changes get reviewed by people outside the owning team.

Thats where the idea becomes more than a toy. It turns code review risk into something visible.

## Where it can go wrong

The biggest danger is false confidence. A low score doesn't mean the change is safe. It means the tool didn't detect obvious risk signals. A bug in a private method can still be expensive. The second danger is noise. If the tool posts a dramatic comment on every PR, people will ignore it. The scoring needs to be conservative. It should prefer useful silence over constant warnings. The third danger is gaming. Once a score becomes a hard gate, people may split changes awkwardly or rename files to avoid rules. That is another reason to begin with advisory reports.

The fourth danger is pretending the score is objective. It isn't. It reflects the team's current understanding of risk. The rules should evolve as the codebase evolves. If a production incident came from a config flag, add a config rule. If a breaking change came from a message contract, add a message contract rule. If a slow outage came from retry behaviour, add a runtime behaviour rule.

The tool should learn from the team's scars.

## What I would add next

After the first path based version, I would add a few deeper checks. OpenAPI diffing would be high value. If the API contract changes, compare generated OpenAPI output before and after the PR. That would catch changed routes, removed fields, altered response codes, and schema changes.

EF migration analysis would also be worth it. Not all migrations are equal. Adding a nullable column is different from dropping a table. A migration that changes precision is different from one that creates a new index.

Message contract detection would help distributed systems. If a record in `Contracts` or `Messages` changes, the tool could flag consumers and publishers. Options validation would be a useful .NET-specific rule. If new configuration is introduced, the tool could check whether the options class uses validation and `ValidateOnStart`.

Test mapping could become smarter over time. The first version can use folder conventions. Later versions could use project references, namespaces, or code coverage reports. None of this needs to happen on day one. The first version should be small enough to trust.

## A possible repo shape

If I built this as a small .NET tool, I would keep the structure plain.

```text
src/
  BlastGuard.Cli/
  BlastGuard.Core/
  BlastGuard.Git/
  BlastGuard.GitHub/

tests/
  BlastGuard.Core.Tests/
  BlastGuard.Cli.Tests/
```

`BlastGuard.Core` owns the rules and scoring.

`BlastGuard.Git` reads diffs locally.

`BlastGuard.GitHub` formats comments and check summaries.

`BlastGuard.Cli` wires it together.

The CLI could start with one command.

```bash
blastguard analyse --base main --head HEAD --format markdown
```

Later, it could support JSON output for CI systems.

```bash
blastguard analyse --base main --head HEAD --format json --output blast-radius.json
```

That keeps the tool useful outside GitHub too.

## The real value is better review behaviour

The best version of this idea doesn't shame people for opening risky PRs. Sometimes a risky PR is necessary. A payment contract needs to change. A migration needs to ship. An auth policy needs fixing. The issue isn't that the PR has risk. The issue is when the risk is hidden.

A blast radius score makes the risk visible. It gives authors a chance to explain the change properly. It gives reviewers a better place to start. It gives teams a shared language for changes that deserve more care. Thats useful even if the score is rough. A pull request shouldn't be judged only by how many lines changed. It should be judged by how far the change can travel. And if a tiny PR can alter contracts, data, config, security, or runtime behaviour, it shouldn't be allowed to stroll through review pretending to be ordinary.

[Try it out in your Github actions here](https://github.com/marketplace/actions/blastguard-pr-blast-radius)
