Ask the planner what a pull request reaches; map a changed file onto modules in one place (hq ADR 0238)

touchedBy is now the only mapping of changed files onto modules — touched, added, and
read by no build — and reachOfMerge the planner's whole answer with the dependency walk.
The merge handler, the plan what-if, the merge gate's width and composition, and a pull
request's check all ask it, so planning and gating cannot disagree. The gate composes
the definitions of the modules a merge would rebuild or add, not every one in the tree,
and the check says the dependents a merge would build after them.
This commit is contained in:
jochen
2026-10-06 22:34:57 +02:00
parent b24bb030ec
commit 58ebe590a5
8 changed files with 363 additions and 198 deletions
+138 -29
View File
@@ -6,6 +6,7 @@ import (
"flag"
"fmt"
"regexp"
"slices"
"sort"
"strings"
"sync"
@@ -616,14 +617,24 @@ func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
}
// whatTheMergeTouched narrows the modules built from a repository to the ones the merge changed: **a
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0237 as amended).
// changed file touches exactly the modules whose build reads it** (novox/hq issue 280, ADR 0238). It is
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
touched, _, _ := touchedBy(candidates, known, m)
return touched
}
// touchedBy maps a merge's changed files onto the mesh's modules — **the one place it is done**, for the
// merge handler, the release planner's what-if, the merge gate and a pull request's check alike (novox/hq
// ADR 0238), so planning and gating cannot disagree about what a change touches.
//
// What a build reads is the module's own directory — the builder clones the repository and builds within
// that directory alone: the manifest, the recipes, the bundles' sources, the Docker context — or the whole
// repository for a module built from its root. A second repository a recipe packages (an artifact's
// `context`) is read too, and that is the build record's `read`, answered by readsFrom beside this. So a
// changed file inside a module's directory is that module's; a file in no module's directory — a script
// at the root, a README, CI configuration, a directory of notes — is read by no build and touches nothing.
// **A changed file touches exactly the modules whose build reads it.** What a build reads is the module's
// own directory — the builder clones the repository and builds within that directory alone: the manifest,
// the recipes, the bundles' sources, the Docker context — or the whole repository for a module built from
// its root. A second repository a recipe packages (an artifact's `context`) is read too; that is the build
// record's `read`, answered by readsFrom in mergeCandidates. So a changed file inside a module's directory
// is that module's; a file in no module's directory — a script at the root, a README, CI configuration —
// is read by no build and touches nothing: it is answered as `unread`.
//
// **It used to be read as shared code**, rebuilding everything built from the repository, on the theory
// that a root file might be a build input (04-ISSUES/131). No build reads one: the theory cost a rebuild
@@ -631,23 +642,131 @@ func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
// held by no machine (278) and a new module's directory (252), each patched as an exception to a rule that
// was wrong. Rebuilding too much is not the safe direction when every rebuild is a rollout.
//
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot be
// narrowed. `known` is kept for the callers; which directories hold a module is no longer needed to read a
// change, because a file is a module's only by being inside it.
func whatTheMergeTouched(candidates, known []inventory.Entry, m link.SourceMoved) []inventory.Entry {
_ = known
if len(m.Paths) == 0 || m.PathsTruncated {
return candidates
}
var out []inventory.Entry
for _, e := range candidates {
if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
out = append(out, e)
// `added` is the directories the change holds a module in that no module of this repository is known
// from — said by the announcer at the head (issue 278), or a module.json among the changed files — `.`
// for the root: a new module, which a check judges before it merges and a merge does not build.
//
// Nothing said about the files, or not all of them said, is still everything: what is not known cannot
// be narrowed.
func touchedBy(candidates, known []inventory.Entry, m link.SourceMoved) (touched []inventory.Entry, added, unread []string) {
knownDirs := map[string]bool{}
for _, e := range known {
if !e.Provided && sameRepository(e.Source.Repository, m) {
knownDirs[strings.Trim(e.Source.Path, "/")] = true
}
}
newDir := map[string]bool{}
for _, d := range saidModuleDirs(m) {
if !knownDirs[d] {
newDir[d] = true
}
}
for _, p := range m.Paths {
p = strings.Trim(p, "/")
if p != moduleManifestFile && !strings.HasSuffix(p, "/"+moduleManifestFile) {
continue
}
d := strings.TrimSuffix(strings.TrimSuffix(p, moduleManifestFile), "/")
if !knownDirs[d] {
if d == "" {
d = "."
}
newDir[d] = true
}
}
for d := range newDir {
added = append(added, d)
}
sort.Strings(added)
if len(m.Paths) == 0 || m.PathsTruncated {
return candidates, added, nil
}
for _, e := range candidates {
if strings.Trim(e.Source.Path, "/") == "" || anyInside(m.Paths, e.Source.Path) {
touched = append(touched, e)
}
}
for _, p := range m.Paths {
read := newDir["."]
for _, e := range candidates {
read = read || strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
}
for d := range newDir {
read = read || inside(p, d)
}
if !read {
unread = append(unread, p)
}
}
return touched, added, unread
}
// mergeReach is what a merge of a repository's branch reaches, as the planner reckons it: the planner's
// one answer, given to the release planner's what-if, the merge gate and a pull request's check (novox/hq
// ADR 0238). The merge handler acts on the same pieces — mergeCandidates, touchedBy, splitDeleted,
// planOfMerge — as it goes.
type mergeReach struct {
// Touched are the modules built from the repository and branch whose build reads a changed file, and
// Deleted those of them whose manifest the merge removes.
Touched, Deleted []inventory.Entry
// Packaging are the modules whose build packages source from the repository.
Packaging []inventory.Entry
// Already counts the modules built from the repository that are built from this very commit.
Already int
// Added are the directories the change holds a new module in; Unread the changed files no build reads.
Added, Unread []string
// Plan is what moves and everything that follows it along the catalogue's dependencies, tiered —
// what a merge would build. Empty when nothing moves.
Plan inventory.Plan
}
// Moved are the modules the merge moves itself, by name: touched, deleted and packaging.
func (r mergeReach) Moved() []string {
var out []string
for _, group := range [][]inventory.Entry{r.Touched, r.Deleted, r.Packaging} {
for _, e := range group {
out = append(out, e.Manifest.Module)
}
}
sort.Strings(out)
return slices.Compact(out)
}
// Dependents are the modules the plan builds after the moved ones because they stand on them.
func (r mergeReach) Dependents() []string {
moved := map[string]bool{}
for _, name := range r.Moved() {
moved[name] = true
}
var out []string
for name := range r.Plan.Modules {
if !moved[name] {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// reachOfMerge is what a merge of these changed files into this branch would move and build, without
// acting: the merge handler's own judgement and the release plan's dependency walk.
func reachOfMerge(m link.SourceMoved, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) mergeReach {
from, packaging, already := mergeCandidates(m, entries, read)
touched, added, unread := touchedBy(from, entries, m)
kept, deleted := splitDeleted(touched, m)
r := mergeReach{Touched: kept, Deleted: deleted, Packaging: packaging, Already: already, Added: added, Unread: unread}
var building []string
for _, e := range append(append([]inventory.Entry{}, kept...), packaging...) {
building = append(building, e.Manifest.Module)
}
if len(building) > 0 {
r.Plan = planOfMerge(m, building, edges)
}
return r
}
// saidModuleDirs is the directories the announcer says hold a module at the merge commit (novox/hq
// issue 278): a changed file in one of them is that module's business and nobody else's — the
// catalogue's reference module, held by no machine, whose code a merge touched beside its manifest,
@@ -674,16 +793,6 @@ func inside(path, dir string) bool {
return path == dir || strings.HasPrefix(path, dir+"/")
}
// insideAny is whether a changed file is in any of these directories.
func insideAny(path string, dirs []string) bool {
for _, dir := range dirs {
if inside(path, dir) {
return true
}
}
return false
}
// anyInside is whether any of these changed files is in a directory.
func anyInside(paths []string, dir string) bool {
for _, p := range paths {