Skip to content
ChangeGuard

Interactive walkthrough9 chaptersengine 0.1.0 · ruleset 2026.10.4

Follow one change through the engine.

A developer adds loyalty discounts to a billing service and switches price formatting from currency codes to locales. The diff is 29 lines across 3 files and reads like routine work. This page runs it through ChangeGuard one stage at a time and shows what each stage established, and how.

The sample is synthetic. A small repository written to exercise the analysers, labelled as such everywhere it appears.

The output is real. Every number and finding below was produced by the engine. Run it yourself and the finding IDs should match.

billing-refactor·SAMPLE · SYNTHETICLooking for an engine…

Inputs

  • change.patchgit patch · 3 files · +24 −5sha256 fe973e6e9b1a9ae7…
  • snapshot.ziprepository before the change · 6 files · 2.3 KB
  • coverage.xmlCobertura report from the test suite

Start the engine to run the sample live. Until then the figure replays a run recorded by the engine at build time.

Event log

waiting

Pipelineready

paced for reading · times measured
  1. Parse inputs: Waiting
  2. Reconstruct revisions: Waiting
  3. Structural diff: Waiting
  4. Cross-file references: Waiting
  5. Static analysis: Waiting
  6. Risk rules: Waiting
  7. Test mapping: Waiting
  8. Coverage: Waiting
  9. AI synthesis: Waiting
  10. Grounding verification: Waiting
  11. Assemble report: Waiting

Ready.

Fig. 1Run the bundled sample on your engine, or replay the recorded run. Stage times are measured; the reveal is paced so it can be followed.
01 / 09The change0.76 ms

01Parse inputs0.68 msReconstruct revisions0.08 ms

Three inputs, two revisions, nothing executed.

ChangeGuard accepts up to three inputs: the patch, a snapshot of the repository before the change, and a coverage report. All three are present here: a git patch touching 3 files (+24 −5), a 6-file snapshot, and a Cobertura report from the test suite.

The patch is parsed strictly. A stray line after a hunk is an error, not something to skip. The parsed patch is then applied to the snapshot in memory to rebuild both revisions. Nothing is written to disk, no archive is extracted, and no script, hook, or test from the repository runs. Every later stage reads these two in-memory revisions.

workspace · in memory6 → 7 files
Files in the snapshot (base revision), the operation the patch applies, and the files in the rebuilt head revision.
FileSnapshotPatchHead
src/billing/__init__.pypresentunchangedpresent
src/billing/api.pypresentmodified+3 −0modified
src/billing/invoice.pypresentunchangedpresent
src/billing/loyalty.pyabsentadded+11 −0added
src/billing/pricing.pypresentmodified+10 −5modified
tests/test_invoice.pypresentunchangedpresent
tests/test_pricing.pypresentunchangedpresent
Patch applied in memory: no checkout, no extraction to diskNo repository script, hook, or test runs
Fig. 2Each file in the snapshot, what the patch does to it, and the head revision rebuilt from the two.

Before going further, read the diff yourself. Which lines would you stop at? When you have an answer, reveal what the engine flagged; the markers sit on the exact lines its findings point at.

@@ -5,16 +5,17 @@
55TAX_RATE = Decimal("0.20")
66
77
8def apply_discount(subtotal: Decimal, percent: int) -> Decimal:
8def apply_discount(subtotal: Decimal, percent: int, loyalty_years: int = 0) -> Decimal:
99 """Apply a percentage discount; discounts above 50% need manual approval."""
10 if percent > 50:
10 percent = percent + min(loyalty_years, 5)
11 if percent >= 50:
1112 raise ValueError("discount requires approval")
1213 return subtotal * (Decimal(100 - percent) / 100)
1314
1415
15def format_price(amount: Decimal, currency: str = "USD") -> str:
16 """Render an amount with its currency symbol."""
17 symbol = {"USD": "$", "EUR": "€", "GBP": "£"}.get(currency, currency + " ")
16def format_price(amount: Decimal, locale: str) -> str:
17 """Render an amount using the symbol for the caller's locale."""
18 symbol = {"en_US": "$", "de_DE": "€", "en_GB": "£"}.get(locale, "")
1819 return f"{symbol}{amount:.2f}"
1920
2021
@@ -22,4 +23,8 @@def total_with_tax(subtotal: Decimal, region: str) -> Decimal:
2223 """Add sales tax for regions that charge it."""
2324 if region == "EU":
2425 return subtotal * (1 + TAX_RATE)
26 try:
27 return subtotal * REGIONAL_RATES[region]
28 except:
29 pass
2530 return subtotal
Fig. 3The complete patch. Select a marker or a row to see the finding behind it.

02Structural diff1.59 ms

5 symbols changed. One broke its contract.

A line diff knows which lines moved. It does not know that format_price is a function, that it had a defaulted currency parameter, or that the new version requires a locale. ChangeGuard parses both revisions with tree-sitter and compares them symbol by symbol: parameters, with their order, kinds and defaults, and return annotations.

Two signatures changed. apply_discount gained a parameter with a default, so every existing call still binds. format_price lost currency and gained a required locale, and the structural diff marks it breaking. Whether that matters depends on who calls it, which is the next stage.

structural diff · tree-sitter5 changed symbols
  • handle_invoiceapi.py:9 · modified
    Body only
    async handle_invoice(request_body: str) -> dict[str, str]
    complexity 1 → 1
  • loyalty_bonusloyalty.py:6 · added
    New
    loyalty_bonus(years: int, tier: str) -> Decimal
    complexity — → 4
  • apply_discountpricing.py:8 · modified
    Compatible
    apply_discount(subtotal: Decimal, percent: int, loyalty_years: int = 0 )
    complexity 2 → 2
  • format_pricepricing.py:16 · modified
    Breaking
    format_price(amount: Decimal, currency: str = "USD", locale: str )
    complexity 1 → 1
  • total_with_taxpricing.py:22 · modified
    Body only
    total_with_tax(subtotal: Decimal, region: str) -> Decimal
    complexity 2 → 3
param removedparam required, addedparam = … added with a defaultVerdict is the engine’s, from the structural diff.
Fig. 4Changed symbols, with each signature change drawn parameter by parameter, and cyclomatic complexity before and after.

03Cross-file references1.01 ms

Every caller, checked the way Python binds arguments.

For each changed signature, ChangeGuard resolves imports across the snapshot to find the call sites. Here those are from billing.pricing import … in invoice.py and in the tests. It then replays each call against the new parameter list using Python’s binding rules: positional slots, keywords, defaults, *args and **kwargs.

6 call sites were checked and 2 no longer bind. render_line passes currency=, a keyword the function no longer accepts, and a test calls format_price with no locale at all. Both would raise TypeError when they run. Together they make one deterministic, high-severity finding, with the call sites as its evidence.

cross-file references · argument binding6 call sites checked · 2 incompatible
format_price3 call sites in 2 files · 2 incompatible
  • Compatible callbinds the new signature
apply_discount3 call sites in 2 files · all bind
  • Compatible callbinds the new signature
  • Compatible callbinds the new signature
  • Compatible callbinds the new signature
E3Call siteCall site src/billing/invoice.py:9 in render_line
8def render_line(description: str, amount: Decimal, currency: str) -> str:
9 return f"{description}: {format_price(amount, currency=currency)}"
10

passes unexpected keyword argument currency

does not pass required argument locale

Resolved through an import
src/billing/invoice.py:9Source: repository snapshot (tree-sitter)

Incompatible call sites are kept as evidence with their exact location. Compatible ones are counted but not stored.

Fig. 5Call sites of the two changed signatures. Select a failing call site to see the evidence the engine kept.

04Static analysis24 ms

Lint the change, not the codebase.

A linter run over a whole repository buries a reviewer in findings that predate the change. ChangeGuard runs Ruff on both revisions of each changed Python file and keeps only the diagnostics that are new in the head revision. Ruff reads the source through stdin with repository configuration ignored, so nothing in the repository can change how the tool behaves, and the analysed code is never executed.

5 diagnostics are new. Two of them, E722 and S110, describe the same bare except: pass on line 28. The report merges them into one finding and records the second rule as corroboration instead of counting the problem twice.

Read the two pricing findings together. REGIONAL_RATES on line 27 is never defined, so the lookup raises NameError, and the bare except on the next line swallows it. Nothing crashes. Every non-EU invoice silently loses its regional tax.

differential lint · ruff 0.16.105 new diagnostics → 4 findings
  • Async functions should not call time.sleep

    • ASYNC251 · api.py:11 · E5
  • print found

    • T201 · api.py:13 · E6
  • Undefined name REGIONAL_RATES

    • F821 · pricing.py:27 · E7
  • Do not use bare except

    • E722 · pricing.py:28 · E8
    • S110 · pricing.py:28 · E9
Diagnostics are matched across revisions by rule code and normalised source line, so code that merely moved is not reported as new.
Fig. 6New diagnostics in the head revision (left) and the findings they become after de-duplication (right).

05Risk rules1.49 ms

Rules that report what they checked, including when nothing was found.

Some risks have no linter: credentials pasted into code, text written to steer a language model, a migration that drops a column, a test whose assertions were deleted. ChangeGuard’s rule set reads the added lines for these. On this change every family came back clear except one.

percent > 50 became percent >= 50. The docstring above it still says discounts above 50% need approval; now exactly 50% does too. Whether that is a fix or a regression is a business question, so the rule makes a heuristic finding with medium confidence, and the suggested check is a boundary-value test.

risk rules · added lines11 families · 1 with matches
Secrets and credentialsclear0
Prompt-injection textclear0
Destructive migrationsclear0
Committed env filesclear0
Risky call patternsclear0
Dependency changesclear0
Condition and operator flipsmatch1
Complexity past thresholdclear0
Removed error handlingclear0
Removed assertionsclear0
Deleted testsclear0

The one match · src/billing/pricing.py:11 · E10

9 """Apply a percentage discount; discounts above 50% need manual approval."""
10−if percent > 50:
11+if percent >= 50:

Highlighted: the unchanged docstring, line 9, which still says above.

Heuristic · CG-LOG-001

Condition logic changed

Severity
medium
Confidence
medium

Suggested check. Add boundary-value tests: the threshold itself, one below, and one above, asserting the intended branch for each.

Fig. 7Each rule family and its number of matches on the added lines, then the one match with its context.

06Test mapping0.38 msCoverage0.42 ms

Which changed lines did any test ever run?

There are two independent signals here. Test mapping is static: it looks for test files that import a changed symbol’s module and reference the symbol. 2 of the 5 changed symbols have such tests. The others, including the new loyalty_bonus and the async handler, have none.

Coverage is measured. The uploaded report records hit counts per line, and ChangeGuard intersects them with the changed lines of the head revision: 4 of 19 changed executable lines ran, which is 21.1% patch coverage. The report gets a plausibility check first. Hits on blank lines, comments, or lines past the end of a file mark it as stale, and that lowers the confidence of every coverage finding instead of being trusted at face value.

test mapping + coverage · cobertura2/5 symbols tested · 4/19 lines ran

Tests that import and reference each symbol

Static: found by reading test files, not by running them.

  • handle_invoicemodifiedno test
  • loyalty_bonusaddedno test
  • apply_discountmodified2 tests
  • format_pricemodified1 test
  • total_with_taxmodifiedno test

Changed executable lines the test suite ran

21.1% patch coverage

src/billing/api.py0/3

  • L4 never ran
  • L11 never ran
  • L13 never ran

src/billing/loyalty.py0/8

  • L1 never ran
  • L3 never ran
  • L6 never ran
  • L7 never ran
  • L8 never ran
  • L9 never ran
  • L10 never ran
  • L11 never ran

src/billing/pricing.py4/8

  • ran
  • ran
  • ran
  • ran
  • L18 never ran
  • L27 never ran
  • L28 never ran
  • L29 never ran
rannever ranStaleness check passed
Fig. 8Left: tests found by static mapping. Right: every changed executable line, coloured by whether the uploaded coverage report says it ran.

07AI synthesis24.1 sGrounding verification0.45 ms

A model may explain. A verifier decides what you see.

With AI enabled, ChangeGuard sends a model a fenced evidence pack containing the findings, their evidence, and the changed code, with each excerpt wrapped as untrusted data. Secrets are masked before the pack is built, and any line that reads like an instruction to the model is withheld. The model must answer in a closed JSON schema in which every claim cites evidence IDs, and the schema has no file or line fields to fill in.

The output then goes to a verifier. It rejects a claim outright, and never repairs it, if the claim cites evidence that does not exist, names a path outside the change, gives a line number outside the cited evidence, attributes a function the evidence does not contain, uses a number the evidence does not support, or reports a test result. ChangeGuard runs no tests, so no model may say one passed.

In the recorded run below, llama3.2:3b, running locally, made 2 claims and 2 passed. One became a note on the call-site finding. It also wrote a free-text summary that puts the condition change in format_price. The change is in apply_discount. That summary is kept in the trace and never shown as analysis.

ai synthesis + grounding verification · recordedollama / llama3.2:3b · local
  1. 01

    Evidence pack

    9,011 chars · trimmed to budget

    excerpts fenced as untrusted

    secrets masked · injection lines withheld

  2. 02

    Model

    llama3.2:3b

    ollama · local inference

    3,510 → 257 tokens · 24.1 s

  3. 03

    Closed schema

    every claim cites evidence IDs

    no file or line fields to fill

    AI risks: confidence ≤ medium

  4. 04

    Verifier

    8 checks per claim

    a failed check rejects the claim

    claims are never repaired

  5. 05

    Result

    2 claims · 2 accepted · 0 rejected

    shown beside the rule’s analysis, never in place of it

✦ Shownverified note on CG-API-001

2 call site(s) incompatible with new signature of format_price

“The new signature of format_price changes the required parameter from currency to locale, making it incompatible with existing call sites.”

cites E4 model confidence high

Not shownfree-text summary, kept in the JSON trace

“The riskiest aspect of this change is the potential for incorrect calculations due to the changed condition logic in `format_price`. This could lead to inaccurate invoices being generated, resulting in financial losses. The new signature of `format_price` also introduces a new source of errors if not handled properly.”

It places the condition change in format_price. The report places it in apply_discount. Prose like this cannot be checked claim by claim, so it is never displayed as analysis.

Checks applied to every claim

  • unknown_findingA note must attach to a finding in this report.
  • no_evidenceEvery claim cites at least one evidence ID.
  • unknown_evidenceEvery cited ID was produced for this change.
  • unknown_pathEvery file path named is part of the change or its evidence.
  • line_out_of_rangeEvery line number falls inside the evidence it cites.
  • symbol_not_in_evidenceEvery function named appears in the cited evidence.
  • unsupported_numberEvery percentage appears in the evidence.
  • claims_test_resultNo claim says a test passed or failed. None were run.
prompt review v1.0.0prompt sha256 459e33c66e82…request sha256 175b290ad4c4…recorded when the site was built
Fig. 9One recorded AI pass, from evidence pack to verified note. How much a model adds, and at what cost in false positives, is measured on the labelled dataset in the evaluation.

08Assemble report0.45 ms

13 findings, kept apart by how they were established.

Findings are de-duplicated, every evidence reference is checked to exist, and each finding gets a stable ID derived from its rule and location, so the same change produces the same IDs on every run. Severity and confidence are separate categorical judgements, defined per rule. They are not probabilities.

The review priority is a written rule, not a score, and the figure lists it in full. Here 2 high-severity deterministic findings set it to high. A model’s findings could never have pushed it past elevated.

report · severity × provenance13 findings · priority High
Findings by severity (rows) and provenance (columns). Select a finding to read it.
DeterministicHeuristicAI-generated
Critical
0
0
0
High
0
0
Medium
0
Low
0
0

sets the review priority

Review priority

First rule that matches, top to bottom. A triage label, not a probability.

  1. Block

    A critical finding established by deterministic analysis.

  2. Highthis change

    Any high-severity deterministic or heuristic finding.

  3. Elevated

    Any medium-severity finding. AI findings alone can raise priority this far and no further.

  4. Routine

    Only low-severity or informational findings.

2 call site(s) incompatible with new signature of format_price

src/billing/pricing.py:16 · CG-API-001 · Breaking change · confidence high

Read it with its evidence →
Fig. 10Every finding, placed by severity and provenance. Ringed findings set the review priority.

09CLI and exports

The same engine in CI, gating only on what it can show.

The CLI runs the identical pipeline on a git range. It writes Markdown for a pull-request comment, SARIF for code scanning, or the full JSON report, and it can fail the build at a severity threshold. AI findings never fail a build. Only deterministic and heuristic findings count.

CI step
changeguard analyze \  --git-repo . --base origin/main --head HEAD \  --coverage coverage.xml \  --format sarif --output changeguard.sarif \  --fail-on high
exit codes
0No non-AI finding at or above the threshold.
1At least one deterministic or heuristic finding at or above it.
2Invalid input or a git error, such as a malformed patch or an unknown revision.

Now try a change of your own.

Paste a diff, or upload a patch with a snapshot and a coverage report. Everything above runs the same way, and nothing leaves the machine the engine runs on unless you configure a hosted model.