by Winch Labs

Reviews

Cross-repo reach

Blast radius answers “what else does this change reach?” inside one repository. Cross-repo reach asks the same question one repository outward: which other repositories in your organisation consume the module that just moved.

It renders as a second labelled paragraph inside the same blockquote:

> **Blast radius** — touches `modules/vpc` · consumed by 4 units across 2 envs (prod: 3)
>
> **Across repos** — consumed by 3 repos across 2 envs (prod: 2) · `acme/api`, `acme/edge`, `acme/web` · 2 pinned, picked up on upgrade · 1 on a different branch · as of 6 consumer scans, oldest 12d · only what this org has scanned is counted, so this is a lower bound

Two labelled paragraphs rather than one continued sentence, because the two carry different freshness contracts. The first is exact against one commit. The second mixes several consumer scans and says so. You have to be able to see which caveat attaches to which number.

The second paragraph can appear without the first, and that case is the point rather than an edge: a repository that publishes modules and consumes none of them in-repo has an empty in-repo radius by construction, so the author who most needs to know that eleven other repositories track this branch is exactly the one the in-repo line tells nothing.

Reading every clause

Segments are joined with ·. Every optional segment is omitted when its count is zero — there are no zero buckets to read past. The final clause is unconditional.

Clause What it means
consumed by 3 repos Repositories with at least one directory that tracks — takes the change as soon as it lands. Not directories: the score below counts those.
across 2 envs (prod: 2) Environments those tracking directories sit in, read from each consumer’s own directory path, production named separately. Omitted entirely when no tracking directory sits somewhere the path heuristic can name — “across 0 envs” is a claim this sentence does not make.
`acme/api`, `acme/edge`, `acme/web` The tracking repositories, capped at five with and N more — the same cap and the same helper the in-repo line uses, so the two paragraphs of one blockquote cannot disagree about how a long list ends.
2 pinned, picked up on upgrade Consumer directories bound to a commit SHA or a version tag. Not reached by this change; reached by a deliberate upgrade.
1 on a different branch Consumer directories whose ref names a branch that is not this pull request’s base. Nothing but a backport carries the change to them.
1 with a ref this build cannot read, counted as tracking Directories whose ref had to be assumed. They are inside the tracking count, and this clause is the amount by which that count may be too high, so you can subtract it.
the consumer list was capped at 5000, so this is a lower bound The review read the maximum number of consumer rows it will read in one query. Named, never turned into silence: a module with six thousand consumers is the pull request that most needs the sentence.
as of 6 consumer scans, oldest 12d How many other repositories’ scans stand behind the answer, and how stale the oldest of them is. Whole units, largest that fits: under an hour, Nh below 48 hours, otherwise Nd.
only what this org has scanned is counted, so this is a lower bound Always present, on every rendering, whatever the counts. Made conditional it would be absent in exactly the estate where it matters most — the one that has scanned least.

When nothing tracks but something is pinned or diverged, the head is replaced by the literal no repo tracks this branch. “Consumed by 0 repos” is not a sentence Deltz writes.

Four worked examples

typical:      consumed by 3 repos across 2 envs (prod: 2) · `acme/api`, `acme/edge`, `acme/web` · 2 pinned, picked up on upgrade · 1 on a different branch · as of 6 consumer scans, oldest 12d · only what this org has scanned is counted, so this is a lower bound

module repo:  consumed by 6 repos across 3 envs (prod: 4) · `acme/api`, `acme/batch`, `acme/edge`, `acme/jobs`, `acme/web` and 1 more · as of 7 consumer scans, oldest 4h · only what this org has scanned is counted, so this is a lower bound

pinned only:  no repo tracks this branch · 4 pinned, picked up on upgrade · as of 4 consumer scans, oldest 11d · only what this org has scanned is counted, so this is a lower bound

honest:       consumed by 2 repos across 1 env (prod: 1) · `acme/api`, `acme/web` · 1 with a ref this build cannot read, counted as tracking · as of 2 consumer scans, oldest 3h · only what this org has scanned is counted, so this is a lower bound

Tracking, pinned, diverged

A consumer’s stance is derived per pull request and never stored. Whether ?ref=release-1.x tracks depends on the branch this pull request targets, which is a property of the pull request and not of the consumer. The stored edge carries only the ref’s kind and its raw value; the stance is decided at read time against the base branch name.

What the consumer’s source says Stance Where it lands
No ref and no version at all tracking the head count, the named list, the score
A version constraint — ~> 5.0, >= 2.1 tracking same
?ref= naming this pull request’s base branch tracking same
?ref= naming a different branch diverged N on a different branch
?ref= a commit SHA (7–64 hex characters) pinned N pinned, picked up on upgrade
?ref= a version-shaped tag — v2.9.0, 0.25.0 pinned same
A ref assembled from a variable, or one that does not parse tracking the head count and N with a ref this build cannot read
A branch ref where the host reported no base branch tracking same

The last two rows are an over-count, in the direction of warning — the correct error for a signal whose job is to say how far a change reaches — and they are the only direction in which this feature errs upward. Because the amount is stated in its own clause with its own number, you can subtract it. Everything else errs downward, which is why the closing clause is unconditional.

A directory that names the same module twice, once tracking and once pinned, is tracking: it is affected now.

Why a ref is never resolved to a commit

Deciding whether ?ref=v2.9.0 and ?ref=main currently point at the same object would take a provider API call, on the webhook thread, per pull request — the exact per-review cost the blast radius already refuses in writing. Doing it at scan time is no better: the tags would have to come from a shallow clone that never fetched them. So Deltz reads a pin the way a person reads it, from its shape, and discloses the two shapes it can misread rather than netting them off:

  • A branch literally named in hex characters — deadbeef — classifies as a SHA and reports as pinned. An under-count, and a bounded one. Requiring exactly 40 characters instead would misread every legitimate abbreviated-SHA pin, which is the same error at far higher frequency.
  • A tag that is not version-shaped reads as a branch, and therefore, when it equals the base branch, as tracking. An over-count.

What does not change

The in-repo paragraph is byte-for-byte what it was. Its score, its environment counts, its unresolved caveat and its rendering are untouched, and so is the comment’s finding format, its three-finding cap and its footer.

Blocking does not move. blast_radius.block_above is read against the in-repo score and nothing else. Cross-repo reach never fails a merge check and never escalates a finding. A control that starts failing merges because a sibling repository was scanned is a control that gets switched off, and this phase has no operating history to justify it.

The one place the two scores meet is the standalone-comment floor. A review with no findings comments when the in-repo score or the cross-repo score is at or above blast_radius.comment_above — the same organisation policy setting, with the same default of 3. Or, not a sum: the two count different populations, and adding them would silently redefine a floor you tuned against in-repo counts. The shipped change reads as “tell me when a clean change reaches production — here, or in another repository.”

The cross-repo score itself is Σ tracking consumer directories × the same environment weights the in-repo score uses (production 3, staging 2, everything else 1). Same scale, same unit, different population — comparable, never added.

How the edges are derived

At scan time, from the same facts. Each repository’s scan reads its own module blocks and Terragrunt units — the facts the parser already produced for that repository’s convention document — and records every source that leaves the repository. Never a text search for source =: in a real repository that matches more provider declarations than module ones. The webhook thread derives nothing and spends exactly one indexed read.

A scan replaces that repository’s rows wholesale. The cache is what each repository declares today, not a history, so a module call deleted from a repository does not survive as a consumer nobody can find.

Scan order does not matter. Repositories are scanned in whatever order discovery reaches them, so a module repository is routinely scanned after the consumers that name it. Each scan therefore also re-resolves the organisation’s still-unresolved edges that name the repository just scanned.

A source resolves only to a repository this organisation has scanned, matched on host and name together. The host is half the identity: on a self-managed GitLab tenant, github.com/acme/vpc is a different repository from the organisation’s own acme/vpc, and matching on the name alone would link the two. A source naming a repository the organisation does not own resolves to nothing — never to another tenant’s repository — and is recorded as not_in_org.

Registry sources are matched by convention, and labelled as such. A source like terraform-aws-modules/vpc/aws is matched to github.com/terraform-aws-modules/terraform-aws-vpc — the naming convention the public registry publishes under — and only when that repository is already scanned in this organisation. The match is recorded as inferred and carries an inferred badge wherever it is shown, because it is a convention being trusted rather than a source naming a repository outright. It is restricted to GitHub, because the convention is GitHub’s. A private registry or Terraform Enterprise source — HOST/NS/NAME/PROVIDER — is never resolved: there the namespace is an organisation’s own and says nothing about a repository name.

The subdirectory is load-bearing. A consumer of github.com/acme/infra//modules/vpc is reached by a change to modules/vpc or anything inside it, and is not reached by a change to modules/rds — or to a sibling file in modules/. A consumer that names the whole repository, with no //subdir, is reached by any infrastructure change in it.

What counts as reached is the union of the changed infrastructure directories and the in-repo transitive consumers of them. That union is what makes a change to modules/base reach a foreign consumer of //modules/vpc, when modules/vpc depends on modules/base in-repo — the standard shared-module layout.

Local sources (./, ../) never become cross-repo edges: the in-repo graph already owns them, so nothing is counted in both paragraphs. For the same reason a repository consuming its own module through a registry source is excluded from its own cross-repo count. A consumer whose scan record has since gone — disconnected, renamed — is dropped rather than counted, because a consumer nobody can name or open is not evidence; that makes the answer a lower bound, which the sentence already says.

Measured, or silent

measured: false means the edge cache could not be read — no store, a deployment that does not keep cross-repo edges, or a read that errored. It is not the same as zero consumers, and the two never render alike: one says nothing outside this repository consumes the change, the other says nobody looked.

When it is unmeasured, the pull request gets nothing at all. Silence rather than an explicit caveat is deliberate: a database blip must not append a disclaimer to every pull request in the estate. The false still travels — on the review record, and on the /graph screen, which has the room to say it.

Two things that look like absence but are measurements:

  • A documentation-only pull request reaches no infrastructure directory, so nothing any consumer could be consuming has moved. The cache is never queried, and the answer is still measured — nothing was reached.
  • “Scanned and shares nothing” is a recorded fact. Every scan writes a marker carrying that repository’s commit and the moment the derivation ran, including a scan that found no remote sources at all. Without it, a repository with nothing to declare and a repository last scanned before this feature shipped would produce identical emptiness.

Cross-repo reach is deliberately not gated on the reviewed repository’s own graph freshness. Those rows come from consumer scans and have no relation to this pull request’s base commit, so a module repository whose own graph is stale — or whose base arrived as a branch name rather than a commit, the common GitLab path — still gets a truthful sentence. The staleness that does matter here is how old the consumer scans are, and that is disclosed in the sentence rather than used as a reason for silence.

The /graph screen

/graph is the organisation-wide view of the same cache: what the estate actually depends on, rather than what one pull request touches. It is served from what each scan wrote and never computed on demand. Any member can read it; there is nothing to write.

A freshness strip sits above everything, not below it — 41 repos scanned · 38 measured · as of 38 consumer scans, oldest 12d — so no figure on the page can be read as a live truth. Each segment degrades to a sentence rather than to a zero.

The map is a bipartite picture: module repositories on the left ordered by consumer count, the repositories that consume them on the right, one curve per reference. Stance is carried by the dash pattern rather than by colour — tracking solid, pinned dashed, diverged dotted — so the distinction survives a reader who cannot separate two hues. It is capped at twelve a side, with the repositories it left out named underneath. Clicking a module opens its row in the table and scrolls to it; hovering only dims the unrelated edges, because a table that jumps under the pointer is unusable.

The table is the product. One row per module: the module identity, the repository that owns it, how many repositories and how many directories consume it, the tracking/pinned/on-a-branch split with zero buckets omitted, environment chips with production first, and the oldest consumer scan behind the row. Expand a row and every consumer is listed individually — repository, directory, environment, the ref as written, the //subdir the source named, an inferred badge where the registry convention did the matching, a ref assumed badge where the ref had to be guessed, and a file:line link into the consumer’s repository at the commit that consumer’s own scan derived from.

One difference from the pull-request sentence worth knowing before it surprises you: the screen has no pull request in view, so it has no base branch to compare a ref against. A consumer bound to ?ref=main is shown there under on a branch. The same consumer counts as tracking on a pull request that targets main. The screen states what it can see; the comment states what it can see given a base.

“Sources we could not read” groups every reference that resolved to nothing, by the reason that defeated it, with a count and up to three openable examples each: names a repository this organisation has not scanned; the source is assembled from a variable; a private registry this build cannot resolve; an archive URL rather than a repository; a source scheme this build does not read; the source could not be parsed; the source was empty. The counts are taken over every reference in the organisation, not over the page shown above them — and the panel says so. This is the panel that makes the feature honest: it is the measured list of what a later phase would have to learn to read.

Repositories that have not been derived are named, not counted. A scanned repository with no derivation marker — scanned before this shipped, or a scan that could not write — has module use that is unknown, not absent. They are listed by name with a link to rescan them, and a rescan moves a repository out of that list even when it consumes nothing.

Nothing on the screen renders a bare zero. A measured, fully derived estate with no cross-repo module use says so in words, with the reasons behind it: how many references named a repository outside the organisation, and how many could not be read. When the edge list is a page rather than the estate, the page says showing the first 5,000 of 8,412 references rather than presenting the page as the total.

What this cannot see

Every limit below pushes the number down. That is why the sentence ends by calling itself a lower bound, unconditionally.

  • Only repositories this organisation has scanned are counted. An unconnected, never-scanned or externally-owned consumer is invisible, and no reading of the number distinguishes it from a repository that genuinely does not consume the module. This is the largest limit and it does not go away with more parsing.
  • terragrunt.stack.hcl unit blocks are not read at all. Terraform module blocks and Terragrunt units are; a unit block in a stack file is out of scope for this phase, and its sources are not seen — not recorded as unresolved, not counted anywhere.
  • A registry version constraint is treated as tracking, without checking the range. ~> 5.0 is counted as picking the change up as soon as it lands. Deltz does not evaluate whether the next published version would actually satisfy the constraint, so a consumer whose constraint excludes the coming release is still counted in the head figure.
  • Only literal sources are counted. A source assembled from interpolation is recorded with its reason and named on /graph, never guessed at. A best-effort identity minted from a placeholder would invent a repository out of a template.
  • No ref is resolved to a commit, with the two shape misreads named above.
  • Self-managed GitLab is matched on hostname. A repository’s host token resolves to the organisation’s configured GitLab instance, or to gitlab.com when none is configured. A repository scanned before its host was recorded is not a resolution target at all — assuming a host would let one organisation’s source resolve into a repository it does not own — and such repositories are named on /graph until a rescan records the host. Separately, a bare gitlab.com/owner/name source is not resolved either: Terraform reads that shape as a registry host and github.com/owner/name as a git shorthand, so either reading would be a guess, and it is recorded as unsupported_scheme instead.
  • The reach test is itself a lower bound when the reviewed repository’s own graph is unavailable — stale, or the base arrived as a branch name. The in-repo transitive half is then missing and only the changed directories remain.
  • The consumer list is capped at 5,000 rows for one review. When the cap is hit the sentence says so and names the number.
  • Cross-repo reach does not block anything in this phase, and does not escalate a finding’s severity.