Skip to content

Semantic Diff

The semantic diff compares two versions of an Effect program at the IR level, not the text level. It reports structural changes (steps added, removed, moved, or renamed) giving you a meaningful view of what changed in the program’s behavior.

This is especially useful when reviewing AI-generated code where the text diff can be large and hard to follow, but the structural change may be quite targeted.

Worked Example: Reviewing an AI Agent’s PR

Section titled “Worked Example: Reviewing an AI Agent’s PR”

An AI coding agent was asked to “make the payment flow production-ready.” The resulting PR touches 40+ lines across services, error types, and the main workflow. A text diff is noisy. The semantic diff tells you exactly what changed structurally.

send-money-before.ts
// Services: TransferValidation, ExchangeRates, Accounts, Transfers
export const sendMoney = (input: TransferRequest) =>
Effect.gen(function* () {
const validation = yield* TransferValidation;
const rates = yield* ExchangeRates;
const accounts = yield* Accounts;
const transfers = yield* Transfers;
const validated = yield* validation.validate(input);
const rate = yield* rates.getRate(validated.fromCurrency, validated.toCurrency);
const balance = yield* accounts.getBalance(validated.senderId);
if (balance < validated.amount) {
return yield* Effect.fail(
new InsufficientFundsError(balance, validated.amount),
);
}
const convertedAmount = Math.round(validated.amount * rate * 100) / 100;
const transfer = yield* transfers.execute({
recipientIban: validated.recipientIban,
amount: convertedAmount,
currency: validated.toCurrency,
});
return { transferId: transfer.transferId, convertedAmount, rate };
});

Railway diagram (before):

flowchart LR
  A[validation <- TransferValidation] -->|ok| B[rates <- ExchangeRates]
  B -->|ok| C[accounts <- Accounts]
  C -->|ok| D[transfers <- Transfers]
  D -->|ok| E[rate <- rates.getRate]
  E -->|ok| F[balance <- accounts.getBalance]
  F -->|ok| G{balance < amount?}
  G -->|ok| H[transfers.execute]
  H -->|ok| Done((Success))
  E -->|err| EE([RateUnavailable])
  G -->|err| GE([InsufficientFunds])
  H -->|err| HE([TransferRejected])

The after: AI-generated “production-ready” version

Section titled “The after: AI-generated “production-ready” version”

The AI agent added:

  • FraudScreening service, screens every transfer before execution
  • Notifications service, sends a confirmation after success
  • Retry with exponential backoff on transfers.execute
  • New error types: FraudRiskError, ConfirmationFailedError
send-money-after.ts
export const sendMoney = (input: TransferRequest) =>
Effect.gen(function* () {
const validation = yield* TransferValidation;
const rates = yield* ExchangeRates;
const accounts = yield* Accounts;
const fraud = yield* FraudScreening; // NEW
const transfers = yield* Transfers;
const notifications = yield* Notifications; // NEW
const validated = yield* validation.validate(input);
yield* fraud.screen(validated); // NEW
const rate = yield* rates.getRate(validated.fromCurrency, validated.toCurrency);
const balance = yield* accounts.getBalance(validated.senderId);
if (balance < validated.amount) {
return yield* Effect.fail(
new InsufficientFundsError(balance, validated.amount),
);
}
const convertedAmount = Math.round(validated.amount * rate * 100) / 100;
const transfer = yield* transfers.execute({ // CHANGED: now has retry
recipientIban: validated.recipientIban,
amount: convertedAmount,
currency: validated.toCurrency,
}).pipe(
Effect.retry(
Schedule.exponential('200 millis').pipe(
Schedule.intersect(Schedule.recurs(2)),
),
),
);
yield* notifications.sendConfirmation({ // NEW
transferId: transfer.transferId,
amount: convertedAmount,
currency: validated.toCurrency,
});
return { transferId: transfer.transferId, convertedAmount, rate };
});

Railway diagram (after):

flowchart LR
  A[validation <- TransferValidation] -->|ok| B[rates <- ExchangeRates]
  B -->|ok| C[accounts <- Accounts]
  C -->|ok| D[fraud <- FraudScreening]
  D -->|ok| E[transfers <- Transfers]
  E -->|ok| F[notifications <- Notifications]
  F -->|ok| G[fraud.screen]
  G -->|ok| H[rate <- rates.getRate]
  H -->|ok| I[balance <- accounts.getBalance]
  I -->|ok| J{balance < amount?}
  J -->|ok| K["transfers.execute + retry"]
  K -->|ok| L[notifications.sendConfirmation]
  L -->|ok| Done((Success))
  G -->|err| GE([FraudRisk])
  H -->|err| HE([RateUnavailable])
  J -->|err| JE([InsufficientFunds])
  K -->|err| KE([TransferRejected])
  L -->|err| LE([ConfirmationFailed])

The two fixture files live in the repo. Run the diff with:

Terminal window
npx effect-analyze \
src/__fixtures__/docs/send-money-before.ts \
src/__fixtures__/docs/send-money-after.ts \
--diff
# Effect Program Diff: sendMoney → sendMoney
## Summary
| Metric | Count |
|--------|-------|
| Added | 8 |
| Removed | 1 |
| Renamed | 0 |
| Moved | 0 |
| Unchanged | 7 |
| Structural changes | 1 |
## Step Changes
- transfers.execute (removed)
+ FraudScreening (added)
+ Notifications (added)
+ fraud.screen (added)
+ transfers.execute(...).pipe (added)
+ notifications.sendConfirmation (added)
+ Effect.retry (added)
+ Schedule.exponential (added)
## Structural Changes
- + retry block added
## Added program: FraudScreening
## Added program: Notifications
  1. 7 unchanged steps: the core flow (validate, get rate, check balance, fail path) is untouched
  2. 1 removed step (transfers.execute): replaced by the .pipe(Effect.retry(...)) variant
  3. 2 new service dependencies: FraudScreening and Notifications are new blast radius
  4. 1 structural change: a retry block was added (exponential backoff on the transfer)
  5. New error surface: the railway diagram shows FraudRisk and ConfirmationFailed as new error branches

Compare the current file with the last commit:

Terminal window
npx effect-analyze HEAD:src/transfer.ts src/transfer.ts --diff

With a single argument and --diff, the analyzer compares HEAD against the working copy:

Terminal window
npx effect-analyze src/transfer.ts --diff

This is equivalent to HEAD:src/transfer.ts src/transfer.ts --diff.

Use any git ref with the ref:path syntax:

Terminal window
# Compare two branches
npx effect-analyze main:src/transfer.ts feature:src/transfer.ts --diff
# Compare specific commits
npx effect-analyze abc123:src/transfer.ts def456:src/transfer.ts --diff
# Compare a tag with the working copy
npx effect-analyze v1.0.0:src/transfer.ts src/transfer.ts --diff

The diff engine classifies every step in both versions:

Change TypeDescriptionExample
UnchangedStep exists in both versions with the same structurevalidation.validate stayed the same
AddedStep exists only in the new versionfraud.screen is new
RemovedStep exists only in the old versiontransfers.execute was replaced
MovedStep exists in both but in a different structural containerA step moved from sequential into Effect.all
RenamedSame callee but different binding nameconst resultconst transfer

Beyond individual steps, the diff also reports:

  • Structural changes: new or removed retry, parallel, race, error-handler blocks
  • Added programs: entirely new Effect programs in the file
  • Removed programs: Effect programs that no longer exist
Terminal window
npx effect-analyze before.ts after.ts --diff

Produces a structured report with a summary table, step changes as a diff block, and structural changes. Best for human review and pasting into PR comments.

Flag removed steps as regressions. Useful in CI to catch when an AI agent accidentally deletes error handling or removes a service call:

Terminal window
npx effect-analyze HEAD:src/transfer.ts src/transfer.ts --diff --regression

In regression mode, the summary includes a hasRegressions flag set to true if any step or structural block was removed. Combine with --format json to parse in CI:

Terminal window
# Fail the build if the AI removed any steps
DIFF=$(npx effect-analyze HEAD:src/transfer.ts src/transfer.ts --diff --regression --format json)
if echo "$DIFF" | jq -e '.[0].summary.hasRegressions' > /dev/null 2>&1; then
echo "REGRESSION: Steps were removed from the Effect program"
exit 1
fi
import { analyze, diffPrograms, renderDiffMarkdown } from "effect-analyzer"
import { Effect } from "effect"
const before = await Effect.runPromise(analyze("./old-version.ts").single())
const after = await Effect.runPromise(analyze("./new-version.ts").single())
const diff = diffPrograms(before, after)
const report = renderDiffMarkdown(diff)
console.log(report)

Additional rendering functions:

import { renderDiffJSON, renderDiffMermaid } from "effect-analyzer"
// JSON output for CI
const json = renderDiffJSON(diff)
// Mermaid diagram with change highlights
const mermaid = renderDiffMermaid(diff)