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
+57 -85
View File
@@ -22,7 +22,8 @@ import (
//
// **The mesh's module graph decides, not the repository.** The controller holds the graph — every module,
// the repository and directory it is built from — and maps the pull request's changed paths onto it by
// the rule the merge handler and the release planner use (whatTheMergeTouched, issue 280): **a changed file
// the planner's own answer (reachOfMerge, touchedBy — the one place a changed file is mapped onto modules,
// for the merge handler, the release planner, the merge gate and this check; issue 280): **a changed file
// touches exactly the modules whose build reads it** — a module's own directory (the whole repository for
// one built from its root), or a repository its recipe packages. A file no build reads — a script at the
// root, a README — touches no module. A directory the change adds a module.json in, which the graph does
@@ -52,14 +53,20 @@ const noMergeCheck = "the repository declares no merge-check.sh: none of its own
var coreModules = map[string]string{"mesh-controller": "mesh-controller", "mesh-host": "mesh-host",
"node-tools": "mesh-tools", "nats": "mesh-catalog"}
// checkScope is what a pull request touches of the mesh's graph.
// checkScope is what a pull request reaches of the mesh's graph, as the planner reckons it.
type checkScope struct {
// Modules are the graph's modules built from the repository into the pull request's base that the
// change touches, sorted; New the directories it adds a module in that the graph does not hold.
Modules []string
New []string
// Manifests are their manifests in the change's tree.
// Modules are the modules a merge of the change would move itself — built from the repository into
// the pull request's base and reading a changed file, or packaging the repository's source — and
// Dependents those the plan would build after them; New the directories it adds a module in.
Modules []string
Dependents []string
New []string
// Manifests are the moved modules' and the new ones' manifests in the change's tree.
Manifests []string
// Width is how many modules a merge would build, in how many tiers; Unread the changed files no
// module's build reads.
Width, Tiers int
Unread []string
// Mesh says the repository is the mesh's: modules are built from it on some branch, its owner is the
// core's, or the change adds a module to it.
Mesh bool
@@ -71,13 +78,35 @@ type checkScope struct {
func (s checkScope) gated() bool { return len(s.Modules)+len(s.New) > 0 }
// pullScope maps a pull request onto the mesh's module graph.
func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][]inventory.ReadRepository) checkScope {
// pullScope is what a pull request reaches: **the planner's own answer** (reachOfMerge), asked as if the
// head were merged into the base — never a mapping of its own, so a change to what a merge touches
// changes what is checked with it (novox/hq ADR 0238).
func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][]inventory.ReadRepository,
edges []inventory.Edge) checkScope {
m := link.SourceMoved{Owner: p.Owner, Repo: p.Repo, Base: p.Base, CloneURL: p.CloneURL, Commit: p.Commit,
Paths: p.Paths, PathsTruncated: p.PathsTruncated, ModuleDirs: p.ModuleDirs, ModuleDirsSaid: p.ModuleDirsSaid}
var s checkScope
var from []inventory.Entry
known := map[string]bool{}
Paths: p.Paths, PathsTruncated: p.PathsTruncated, Removed: p.Removed, ModuleDirs: p.ModuleDirs,
ModuleDirsSaid: p.ModuleDirsSaid}
r := reachOfMerge(m, entries, read, edges)
s := checkScope{Modules: r.Moved(), Dependents: r.Dependents(), New: r.Added, Unread: r.Unread,
Width: len(r.Plan.Modules), Tiers: len(r.Plan.Tiers)}
for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) {
s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), moduleManifestFile))
switch e.Manifest.Module {
case "mesh-controller":
s.Judge = link.JudgeSelf
case "mesh-host":
if s.Judge == "" {
s.Judge = link.JudgeValidator
}
}
}
for _, d := range r.Added {
s.Manifests = append(s.Manifests, path.Join(d, moduleManifestFile))
}
sort.Strings(s.Manifests)
s.Manifests = slices.Compact(s.Manifests)
// Whose repository it is, for how it is cloned and whether its own check is the mesh's to run.
owners := map[string]bool{}
for i, e := range entries {
if e.Provided {
@@ -88,77 +117,11 @@ func pullScope(p link.PullUpdated, entries []inventory.Entry, read map[string][]
owners[owner] = true
}
}
if !sameRepository(e.Source.Repository, m) {
continue
}
s.Mesh = true
known[strings.Trim(e.Source.Path, "/")] = true
if s.From == nil {
if sameRepository(e.Source.Repository, m) && s.From == nil {
s.From = &entries[i]
}
if sourceIs(e.Source, m) {
from = append(from, e)
}
}
touched := map[string]inventory.Entry{}
if len(from) > 0 {
for _, e := range whatTheMergeTouched(from, entries, m) {
touched[e.Manifest.Module] = e
}
}
// A module whose build packages source from this repository's branch is touched by any change to it.
for _, e := range entries {
if !e.Provided && readsFrom(read[e.Manifest.Module], m) {
touched[e.Manifest.Module] = e
}
}
for name, e := range touched {
s.Modules = append(s.Modules, name)
if sameRepository(e.Source.Repository, m) {
s.Manifests = append(s.Manifests, path.Join(strings.Trim(e.Source.Path, "/"), "module.json"))
}
switch name {
case "mesh-controller":
s.Judge = link.JudgeSelf
case "mesh-host":
if s.Judge == "" {
s.Judge = link.JudgeValidator
}
}
}
// A module the graph does not hold yet, in a directory the head says holds one — or at the root.
for _, d := range saidModuleDirs(m) {
if !known[d] {
s.New = append(s.New, d)
s.Manifests = append(s.Manifests, d+"/module.json")
}
}
// And, said or not, a manifest the change adds or moves in a directory the graph does not know.
for _, changed := range p.Paths {
changed = strings.Trim(changed, "/")
if path.Base(changed) != "module.json" {
continue
}
d := path.Dir(changed)
if d == "." {
d = ""
}
if !known[d] {
if d == "" {
d = "."
}
s.New = append(s.New, d)
s.Manifests = append(s.Manifests, changed)
}
}
sort.Strings(s.Modules)
sort.Strings(s.New)
s.New = slices.Compact(s.New)
sort.Strings(s.Manifests)
s.Manifests = slices.Compact(s.Manifests)
if owners[strings.ToLower(p.Owner)] || s.gated() {
s.Mesh = true
}
s.Mesh = s.From != nil || owners[strings.ToLower(p.Owner)] || s.gated()
return s
}
@@ -182,7 +145,11 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
if err != nil {
return err
}
scope := pullScope(p, entries, read)
edges, err := inv.Dependencies(ctx)
if err != nil {
return err
}
scope := pullScope(p, entries, read, edges)
direct := link.Checked{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Commit: p.Commit,
ID: link.NewBuildID(time.Now()), Verdict: "pass", Summary: noModuleTouched,
Gate: &link.CheckLayer{Verdict: "pass", Summary: noModuleTouched}}
@@ -213,7 +180,9 @@ func (f following) PullUpdated(ctx context.Context, p link.PullUpdated) error {
}
what := "its own merge-check.sh alone: " + noModuleTouched
if scope.gated() {
what = "the gate over " + strings.Join(append(append([]string{}, scope.Modules...), scope.New...), ", ")
what = fmt.Sprintf("the gate over %s (a merge would build %d module(s) in %d tier(s))",
strings.Join(append(append([]string{}, scope.Modules...), prefixedAll("new:", scope.New)...), ", "),
scope.Width, scope.Tiers)
}
fmt.Printf("%s/%s#%d (%.8s): asked %s to check it before it merges — %s — as %s\n", p.Owner, p.Repo, p.Number,
p.Commit, seat, what, request.ID)
@@ -289,8 +258,8 @@ func checkRequestFor(ctx context.Context, open *stores, p link.PullUpdated, scop
Seats: seatBases(ctx),
Source: sourceOnSeat(source),
Check: &link.CheckRequest{Owner: p.Owner, Repo: p.Repo, Number: p.Number, Base: p.Base,
Paths: p.Paths, Beside: beside, Modules: scope.Modules, New: scope.New, Manifests: scope.Manifests,
Judge: scope.Judge},
Paths: p.Paths, Beside: beside, Modules: scope.Modules, Dependents: scope.Dependents, New: scope.New,
Manifests: scope.Manifests, Judge: scope.Judge},
}, nil
}
@@ -354,6 +323,9 @@ func checkedOf(result link.BuildResult) link.Checked {
if result.Checked != nil && c.Gate != nil && len(c.Gate.Modules) == 0 {
c.Gate.Modules = append(append([]string{}, result.Checked.Modules...), prefixedAll("new:", result.Checked.New)...)
}
if result.Checked != nil && c.Gate != nil && len(c.Gate.Dependents) == 0 {
c.Gate.Dependents = result.Checked.Dependents
}
for _, l := range []*link.CheckLayer{c.Gate, c.RepoCheck} {
if l != nil && l.Verdict == "" {
l.Verdict = "error"