Skip to content

Type to search pages.

View .md

Change analysis

Every check in this product costs something: a branch of a golden, a build, a browser, a few minutes of a runner. Running all of it on a change to a README is waste, and running the default on a change that adds a column and edits the billing service is not enough attention.

af change reads the diff and says which checks will exercise what it touched. It names the file and the rule behind every line of it, so the reasoning can be argued with.

af change against the base branch this job names
af change --base origin/main against a ref you choose
af change --diff pr.patch against a diff you already have
4 files changed, touching the schema, the api service and an outbound host. 5 checks will run, and 1 more is selected and not configured.
run environment api/billing.ts: the manifest declares the service api at the repository root, so every file in the repository is part of it (and 7 more)
run migration migrations/20260824_add_billing_status.sql: the path is inside a migrations directory
run invariants migrations/20260824_add_billing_status.sql: the path is inside a migrations directory
run workflows api/billing.ts: the manifest declares the service api at the repository root, so every file in the repository is part of it (and 6 more)
gap load load is off in the manifest, so af ci runs it only when it is handed --load
run egress api/billing.ts: an added line names api.stripe.com, which the manifest routes to mode mock (and 1 more)
skip masking nothing this change touches is exercised by it

Each line names one reason and counts the rest, because a check selected by eight files does not need eight sentences to justify it. af change -o json carries all of them.

It does not say whether a change is safe, and it does not grade it. There is no score and no risk word in the output, because both would be a judgement made from a file listing, and this product’s whole argument is that judgement comes from running the thing.

What it produces is one shape of sentence: this file is X, and X is exercised by check Y. Every conclusion carries the path that produced it and the name of the rule that fired, so a wrong classification can be found and corrected rather than argued with.

If any changed path matches no rule, every check is selected. The same is true of a diff too large to classify and of a diff with no files in it, which is either an empty change or the wrong base ref, and nothing here can tell those apart.

This is a deliberate asymmetry. A path wrongly classified as documentation skips work that should have happened and nobody finds out; a path wrongly treated as unknown costs a run that was not needed and is visible in the report. Only one of those two mistakes is discoverable.

The consequence to expect: a repository with an unusual layout will select everything until its manifest says otherwise, and that is the intended behaviour rather than a bug to file.

Surface What it is Selects
schema a migration directory, a .sql file, a schema a migration tool reads environment, migration, invariants, load
service a file under a path a service in the manifest declares environment, workflows
code application source environment, workflows, load
asset something the application serves: a stylesheet, an image, a template environment, workflows
build a Dockerfile, a compose file, a build configuration environment
dependency a package manifest or a lockfile environment, egress
config configuration the application reads environment, workflows
manifest antifailure.yaml itself environment, egress
masking the masking rules file the manifest names masking
egress an outbound host named in an added line egress
infrastructure infrastructure as code nothing
pipeline continuous integration configuration nothing
test your own test suite nothing
docs prose nothing

The four surfaces that select nothing are not oversights. The environment is built from antifailure.yaml rather than from your Terraform, nothing in a run reads your workflow files, and this product runs the workflows the manifest declares rather than your test suite.

A check is reported twice: whether this change selects it, and whether the manifest configures it at all. The interesting line is the one that is both selected and unavailable, because it means something changed and nothing is going to look at it.

gap invariants the manifest declares no invariants, so nothing is asked of the data after the workflows

A report that showed only “invariants: not run” would read the same whether the change did not need them or whether nobody ever wrote any.

An added line naming an http or https URL is checked against the egress policy, using the same code that decides real traffic in the sidecar. So a pull request that starts calling something new says so before the run:

egress hooks.slack.com: an added line names hooks.slack.com, which no egress
rule matches, so the default of block applies

Only added lines are read, and only in source, configuration and the manifest. A URL in a README is a link and not a call.

The built in rules cover the conventions most projects use. A repository that puts something somewhere they do not predict declares it:

change:
rules:
- path: packages/*/src/**
surface: code
- path: ops/**
surface: infrastructure
note: the deployment scripts, which no environment runs

A single star does not cross a slash and a double star does. The longest matching pattern wins, so order does not decide and appending a rule cannot silently change what an existing one does.

Three things a rule cannot do. It cannot assign service, manifest, masking or egress, which come from declarations already in the manifest and would be a second answer to disagree with the first. It cannot turn a check off, because a rule says what a path is and the engine decides what that implies. And it cannot match every path: a catch all would classify everything and the fail safe above would never fire again, so the manifest refuses one.

change.rules[0].path: The change rule pattern "**" matches every path.

Inside a GitHub Actions job, af change writes one output per check, so a later step can skip work this change does not need:

- id: change
run: af change
- name: The full check
if: steps.change.outputs.environment == 'true'
run: af ci

The value is the check being both selected by the change and configured in the manifest, because a step asking whether to do work needs both. selected holds the same list as a comma separated string.

Stated in the report itself, on every run, because a report that implies coverage it does not have is worse than no report:

  • It reads paths and added lines. It does not run the program, so a one line change to a configuration default can change behaviour nothing here can see, and a thousand line refactor that changes nothing will still select every check its files touch.
  • A caller left behind in a file the diff does not touch is invisible. The build is what finds that.
  • Columns a migration adds do not exist in the golden yet, so nothing has checked whether they will need a masking rule once they carry production data. The masking check reads the golden, not the diff.
  • A rename is classified by the new path, so moving a file between categories changes the classification without changing a line of code.
  • A binary file has no added lines to read.
  • The workflow agents drive a browser, so a change to a worker or a cron service is exercised only where the application’s own interface reaches it, and a diff cannot say whether it does.

A check that is not selected was not run. That is a statement about what was exercised, not a finding that the untouched parts are correct.

AF-DET-010 is the common one, and it is almost always a shallow checkout: a job cloned one commit deep shares no history with its base branch, so there is no merge base to diff against. fetch-depth: 0 fixes it.

AF-DET-011 means the file passed to --diff is not git’s unified format. Produce it with git diff --unified=0 base...head.