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
+88 -68
View File
@@ -206,9 +206,25 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
}
change := base
sources := sourcesOfFacts(in.facts)
// **What the change moves is the planner's answer** (reachOfMerge, novox/hq ADR 0238), asked of the
// snapshot as if the change were merged: the definitions composed with the change are those of the
// modules a merge of it would rebuild, and of the modules it adds — not every definition in the tree,
// which may differ from what the mesh holds for reasons that are not this change's (a module pinned
// at an older commit, a main the mesh has not built yet).
var reach *mergeReach
if in.repository != "" && len(in.changed) > 0 {
r, err := reachOfChange(in.facts, in.repository, in.changed, in.tree)
if err != nil {
v.Notes = append(v.Notes, "what a merge of the change would rebuild could not be worked out, so every "+
"definition in its tree is judged: "+err.Error())
} else {
reach = &r
}
}
if in.tree != "" {
var failures []string
change, failures, err = shelfWithChange(base, sources, in.facts, in.repository, in.tree, v.Modules)
change, failures, err = shelfWithChange(base, sources, in.facts, in.repository, in.tree, v.Modules,
scopeOfReach(reach, in.repository))
if err != nil {
return v, err
}
@@ -306,20 +322,16 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
}
}
if in.repository != "" && len(in.changed) > 0 {
w, err := rebuildWidth(in.facts, in.repository, in.changed, in.tree)
if err != nil {
v.Notes = append(v.Notes, "the width of the rebuild could not be worked out: "+err.Error())
} else {
v.Width = &w
if len(w.Unread) > 0 {
v.Notes = append(v.Notes, fmt.Sprintf("%s read by no module's build, and rebuild nothing (issue 280)",
readableList(w.Unread)))
}
if len(w.Modules) > wideRebuild {
v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s) in %d tier(s)",
len(w.Modules), w.Tiers))
}
if reach != nil {
w := widthOf(*reach)
v.Width = &w
if len(w.Unread) > 0 {
v.Notes = append(v.Notes, fmt.Sprintf("%s read by no module's build, and rebuild nothing (issue 280)",
readableList(w.Unread)))
}
if len(w.Modules) > wideRebuild {
v.Warnings = append(v.Warnings, fmt.Sprintf("a merge rebuilds %d module(s) in %d tier(s)",
len(w.Modules), w.Tiers))
}
}
@@ -443,8 +455,12 @@ var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules"
// read by this controller's strict parser — the one the mesh runs, for a change to a catalogue — resolved
// with stand-in builds, and read again as registration reads a built one. A module the repository held
// and the tree no longer has is removed. Answers the shelf and what the tree itself fails.
//
// `scope` is the directories of the modules a merge of the change would rebuild or add, as the planner says
// (scopeOfReach); a definition elsewhere in the tree is left as the mesh holds it. Nil judges every one.
func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inventory.Source, f snapshot.Facts,
repository, tree string, moved map[string]string) (map[string]catalogue.Manifest, []string, error) {
repository, tree string, moved map[string]string, scope map[string]bool) (map[string]catalogue.Manifest, []string, error) {
inScope := func(dir string) bool { return scope == nil || scope[dir] }
out := make(map[string]catalogue.Manifest, len(base))
for k, m := range base {
out[k] = m
@@ -477,16 +493,23 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
}
m, err := catalogue.ParseManifest(raw)
if err != nil {
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
orRoot(dir), version, oneLine(err.Error())))
if inScope(dir) {
failures = append(failures, fmt.Sprintf("%s: the controller judging this (%s) refuses it — %s",
orRoot(dir), version, oneLine(err.Error())))
}
return nil
}
if was, twice := found[m.Module]; twice {
failures = append(failures, fmt.Sprintf("%s and %s both define %s: two definitions of one module",
orRoot(was), orRoot(dir), m.Module))
if inScope(dir) || inScope(was) {
failures = append(failures, fmt.Sprintf("%s and %s both define %s: two definitions of one module",
orRoot(was), orRoot(dir), m.Module))
}
return nil
}
found[m.Module] = dir
if !inScope(dir) {
return nil
}
if s, held := sources[m.Module]; held && s.Repository != "" && !sameRepository(s.Repository,
link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}) {
failures = append(failures, fmt.Sprintf("%s defines %s, which the mesh builds from %s: two definitions "+
@@ -538,6 +561,9 @@ func shelfWithChange(base map[string]catalogue.Manifest, sources map[string]inve
if _, still := found[name]; still {
continue
}
if !inScope(strings.Trim(s.Path, "/")) {
continue
}
delete(out, name)
moved[name] = "removed"
if on := running[name]; len(on) > 0 {
@@ -961,25 +987,32 @@ func standInKey() (string, error) {
return base64.StdEncoding.EncodeToString(k.PublicKey().Bytes()), nil
}
// mergeWidth is what a merge of the change would rebuild, computed the way the merge handler computes
// it, from the modules, sources and edges the snapshot carries.
func rebuildWidth(f snapshot.Facts, repository string, paths []string, tree string) (mergeWidthOf, error) {
// reachOfChange is what a merge of the change would move and build — **the planner's own answer**
// (reachOfMerge), asked of the modules, sources, reads and edges the snapshot carries, never a mapping of
// the gate's own: what a merge touches and what follows it are decided in one place, so the gate and the
// plan a merge makes cannot disagree (novox/hq ADR 0238).
func reachOfChange(f snapshot.Facts, repository string, paths []string, tree string) (mergeReach, error) {
owner, repo, found := strings.Cut(repository, "/")
if !found {
return mergeWidthOf{}, fmt.Errorf("%q is not owner/repository", repository)
return mergeReach{}, fmt.Errorf("%q is not owner/repository", repository)
}
m := link.SourceMoved{Owner: owner, Repo: repo, Base: "main", Commit: "gate", Paths: paths}
// Which changed directories hold a module, read from the change's tree as the forge's announcer reads
// them at the merge commit (issue 278): a file inside one is that module's business, held or not.
// them at the head (issue 278): what the planner is told of a module the mesh does not hold yet.
if tree != "" {
m.ModuleDirs, m.ModuleDirsSaid = moduleDirsIn(tree, paths), true
for _, p := range paths {
if _, err := os.Stat(filepath.Join(tree, p)); os.IsNotExist(err) {
m.Removed = append(m.Removed, p)
}
}
}
var entries []inventory.Entry
read := map[string][]inventory.ReadRepository{}
for _, mod := range f.Modules {
var manifest catalogue.Manifest
if err := json.Unmarshal(mod.Manifest, &manifest); err != nil {
return mergeWidthOf{}, err
return mergeReach{}, err
}
entries = append(entries, inventory.Entry{Manifest: manifest, Provided: mod.Provided,
Source: inventory.Source{Repository: mod.Repository, Path: mod.Path, BuiltFrom: mod.Commit}})
@@ -991,52 +1024,39 @@ func rebuildWidth(f snapshot.Facts, repository string, paths []string, tree stri
for _, e := range f.Edges {
edges = append(edges, inventory.Edge{From: e.From, To: e.To, Kind: e.Kind})
}
var from []inventory.Entry
for _, e := range entries {
if !e.Provided && sourceIs(e.Source, m) {
from = append(from, e)
return reachOfMerge(m, entries, read, edges), nil
}
// widthOf is how wide the rebuild of a reach is.
func widthOf(r mergeReach) mergeWidthOf {
w := mergeWidthOf{Tiers: len(r.Plan.Tiers), Unread: r.Unread}
for name := range r.Plan.Modules {
w.Modules = append(w.Modules, name)
}
sort.Strings(w.Modules)
return w
}
// scopeOfReach is the directories, in the change's repository, of the modules a reach rebuilds or adds;
// nil — every definition judged — when there is no reach.
func scopeOfReach(r *mergeReach, repository string) map[string]bool {
if r == nil {
return nil
}
scope := map[string]bool{}
same := link.SourceMoved{Owner: ownerOf(repository), Repo: repoOf(repository)}
for _, e := range append(append([]inventory.Entry{}, r.Touched...), r.Deleted...) {
if sameRepository(e.Source.Repository, same) {
scope[strings.Trim(e.Source.Path, "/")] = true
}
}
touched := whatTheMergeTouched(from, entries, m)
var names []string
for _, e := range touched {
names = append(names, e.Manifest.Module)
}
for _, e := range entries {
if readsFrom(read[e.Manifest.Module], m) && !slices.Contains(names, e.Manifest.Module) {
names = append(names, e.Manifest.Module)
for _, d := range r.Added {
if d == "." {
d = ""
}
scope[d] = true
}
w := mergeWidthOf{}
if len(names) > 0 {
p := planOfMerge(m, names, edges)
w.Tiers = len(p.Tiers)
for name := range p.Modules {
w.Modules = append(w.Modules, name)
}
sort.Strings(w.Modules)
}
// The paths no build reads: in no directory of a module the mesh holds from this repository, or one
// the tree holds.
var dirs []string
for _, e := range from {
if d := strings.Trim(e.Source.Path, "/"); d != "" {
dirs = append(dirs, d+"/")
}
}
for _, d := range m.ModuleDirs {
dirs = append(dirs, strings.Trim(d, "/")+"/")
}
for _, p := range paths {
inModule := false
for _, d := range dirs {
inModule = inModule || strings.HasPrefix(p, d)
}
if !inModule && len(dirs) > 0 {
w.Unread = append(w.Unread, p)
}
}
return w, nil
return scope
}
// moduleDirsIn are the directories above the changed paths, never the root, that hold a module.json in