issue 1025: the merge gate judges a group's move of a module as a move (ADR 0420 §6)

This commit is contained in:
2026-10-17 15:43:28 +02:00
parent 21414307c8
commit 085b661936
2 changed files with 245 additions and 3 deletions
+124 -3
View File
@@ -271,6 +271,25 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
// The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one // The change, then each other head of its delivery group laid over it in turn (novox/hq ADR 0239): one
// future state of the mesh, judged as the group would leave it. // future state of the mesh, judged as the group would leave it.
heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...) heads := append([]groupChange{{Repository: in.repository, Tree: in.tree, Changed: in.changed}}, in.group...)
// **A module one head removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025): neither
// the removal of a module machines run (ADR 0236) nor a second definition of one module name (issue 239).
// The change is judged with the module built from the repository that now defines it.
moves, err := movesInGroup(heads, sources)
if err != nil {
return v, err
}
changeSources := sources
if len(moves) > 0 {
changeSources = make(map[string]inventory.Source, len(sources))
for k, o := range sources {
changeSources[k] = o
}
for name, mv := range moves {
o := changeSources[name]
o.Repository, o.Path = mv.To, mv.Dir
changeSources[name] = o
}
}
for i, h := range heads { for i, h := range heads {
var r *mergeReach var r *mergeReach
if h.Repository != "" && len(h.Changed) > 0 { if h.Repository != "" && len(h.Changed) > 0 {
@@ -289,13 +308,23 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
continue continue
} }
var failures []string var failures []string
change, failures, err = shelfWithChange(change, sources, in.facts, h.Repository, h.Tree, v.Modules, scope := scopeOfReach(r, h.Repository)
scopeOfReach(r, h.Repository)) if scope != nil {
for _, mv := range moves {
if mv.To == h.Repository {
scope[mv.Dir] = true
}
}
}
change, failures, err = shelfWithChange(change, changeSources, in.facts, h.Repository, h.Tree, v.Modules, scope)
if err != nil { if err != nil {
return v, err return v, err
} }
v.Failures = append(v.Failures, failures...) v.Failures = append(v.Failures, failures...)
} }
for name := range moves {
v.Modules[name] = "moved"
}
if len(in.group) > 0 { if len(in.group) > 0 {
say("a delivery group: %d head(s) composed together", len(heads)) say("a delivery group: %d head(s) composed together", len(heads))
} }
@@ -332,7 +361,7 @@ func judgeChange(ctx context.Context, in mergeCheckInput) (mergeVerdict, error)
} }
} }
sort.Strings(added) sort.Strings(added)
after, tried, notes, err := composeEveryMachine(ctx, in, change, sources, added) after, tried, notes, err := composeEveryMachine(ctx, in, change, changeSources, added)
if err != nil { if err != nil {
return v, fmt.Errorf("the mesh with the change could not be raised: %w", err) return v, fmt.Errorf("the mesh with the change could not be raised: %w", err)
} }
@@ -531,6 +560,98 @@ func sourcesOfFacts(f snapshot.Facts) map[string]inventory.Source {
return out return out
} }
// moveOfModule is a module a delivery group moves: the repository the mesh builds it from, the one whose
// head now defines it, and its directory there.
type moveOfModule struct{ From, To, Dir string }
// movesInGroup is the modules one head of a group removes and another defines (novox/hq ADR 0420 §6): a name
// the mesh builds from a head's repository that the head's tree no longer defines, and some other head's tree
// defines. A name removed by a head with no other head defining it, or defined by a head the mesh builds from
// another repository with none removing it, is no move.
func movesInGroup(heads []groupChange, sources map[string]inventory.Source) (map[string]moveOfModule, error) {
if len(heads) < 2 {
return nil, nil
}
defined := make([]map[string]string, len(heads))
for i, h := range heads {
if h.Tree == "" {
continue
}
d, err := namesDefinedIn(h.Tree)
if err != nil {
return nil, err
}
defined[i] = d
}
var moves map[string]moveOfModule
for name, s := range sources {
if s.Repository == "" {
continue
}
for i, h := range heads {
if h.Tree == "" || !sameRepository(s.Repository, link.SourceMoved{Owner: ownerOf(h.Repository), Repo: repoOf(h.Repository)}) {
continue
}
if _, still := defined[i][name]; still {
break
}
// Removed by head i: defined by another?
for j, o := range heads {
if dir, ok := defined[j][name]; ok && j != i && o.Repository != h.Repository {
if moves == nil {
moves = map[string]moveOfModule{}
}
moves[name] = moveOfModule{From: h.Repository, To: o.Repository, Dir: dir}
}
}
break
}
}
return moves, nil
}
// namesDefinedIn is the module names a tree's module.json files define, each with its directory; a manifest
// that does not read is left to shelfWithChange to refuse.
func namesDefinedIn(tree string) (map[string]string, error) {
found := map[string]string{}
err := filepath.WalkDir(tree, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
if path != tree && (ignoredInTree[d.Name()] || strings.HasPrefix(d.Name(), ".")) {
return filepath.SkipDir
}
return nil
}
if d.Name() != "module.json" {
return nil
}
raw, err := os.ReadFile(path)
if err != nil {
return err
}
var m struct {
Module string `json:"module"`
}
if json.Unmarshal(raw, &m) != nil || m.Module == "" {
return nil
}
dir, err := filepath.Rel(tree, filepath.Dir(path))
if err != nil {
return err
}
if dir == "." {
dir = ""
}
if _, twice := found[m.Module]; !twice {
found[m.Module] = dir
}
return nil
})
return found, err
}
// ignoredInTree are directories a module.json in is not a module of the repository. // ignoredInTree are directories a module.json in is not a module of the repository.
var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules": true, "testdata": true, var ignoredInTree = map[string]bool{".git": true, "vendor": true, "node_modules": true, "testdata": true,
"examples": true, "fixtures": true} "examples": true, "fixtures": true}
+121
View File
@@ -434,3 +434,124 @@ func sdkPinnedByGoMod(t *testing.T) string {
t.Fatalf("go.mod requires no %s", sdkModule) t.Fatalf("go.mod requires no %s", sdkModule)
return "" return ""
} }
// **A module one head of a group removes and another defines is a move** (novox/hq ADR 0420 §6, issue 1025):
// neither a second definition of one module name (issue 239) nor the removal of a module machines run
// (ADR 0236). Alone, each head is still refused. This lays the heads over the shelf as judgeChange does,
// which needs no database.
func TestAGroupsMoveOfAModuleIsNeitherTwoDefinitionsNorARemoval(t *testing.T) {
const catalogueRepo, controllerRepo = "novox/mesh-catalog", "novox/mesh-controller"
built := func(repo, path string) inventory.Source {
return inventory.Source{Repository: "http://forge.internal:20000/" + repo + ".git", Path: path, BuiltFrom: "c0ffee"}
}
sources := map[string]inventory.Source{"mover": built(catalogueRepo, "modules/mover")}
raw := `{"module":"mover","version":"1"}`
base := map[string]catalogue.Manifest{}
m, err := catalogue.ParseManifest([]byte(raw))
if err != nil {
t.Fatal(err)
}
base["mover"] = m
facts := snapshot.Facts{Machines: []snapshot.Machine{{Name: "anchor", Assigned: []string{"mover"}}}}
catalogueWithout := aTree(t, map[string]string{"other": `{"module":"other","version":"1"}`})
controllerWith := func() string {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, "modules", "mover"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "modules", "mover", "module.json"), []byte(raw), 0o644); err != nil {
t.Fatal(err)
}
return dir
}()
removes := groupChange{Repository: catalogueRepo, Tree: catalogueWithout, Changed: []string{"modules/mover/module.json"}}
defines := groupChange{Repository: controllerRepo, Tree: controllerWith, Changed: []string{"modules/mover/module.json"}}
lay := func(heads ...groupChange) (map[string]catalogue.Manifest, []string, map[string]string) {
t.Helper()
moves, err := movesInGroup(heads, sources)
if err != nil {
t.Fatal(err)
}
at := sources
if len(moves) > 0 {
at = map[string]inventory.Source{}
for k, v := range sources {
at[k] = v
}
for name, mv := range moves {
o := at[name]
o.Repository, o.Path = mv.To, mv.Dir
at[name] = o
}
}
shelf, said := base, map[string]string{}
var failures []string
for _, h := range heads {
var f []string
if shelf, f, err = shelfWithChange(shelf, at, facts, h.Repository, h.Tree, said, nil); err != nil {
t.Fatal(err)
}
failures = append(failures, f...)
}
for name := range moves {
said[name] = "moved"
}
return shelf, failures, said
}
for _, order := range [][]groupChange{{removes, defines}, {defines, removes}} {
shelf, failures, said := lay(order...)
if len(failures) > 0 {
t.Fatalf("a move of a module machines run, laid %s first, was refused: %v", order[0].Repository, failures)
}
if said["mover"] != "moved" {
t.Errorf("the move reads %v, not moved", said)
}
if _, held := shelf["mover"]; !held {
t.Errorf("the moved module is not on the shelf after laying %s first", order[0].Repository)
}
}
// Alone, a head is still refused: a second definition (issue 239), and a removal of what runs (ADR 0236).
if _, failures, _ := lay(defines); len(failures) != 1 || !strings.Contains(failures[0], "issue 239") {
t.Errorf("a definition of a module another repository builds, alone: %v", failures)
}
if _, failures, _ := lay(removes); len(failures) != 1 || !strings.Contains(failures[0], "ADR 0236") {
t.Errorf("the removal of a module machines run, alone: %v", failures)
}
}
// A group that moves a module machines run passes the gate whole, in either order of its heads, and says the
// module moved (novox/hq ADR 0420 §6, issue 1025); a head alone is still refused. Needs the test store.
func TestADeliveryGroupMovingAModuleMachinesRunPassesTheGate(t *testing.T) {
f, manifests := catalogueMeshWithAnApp(t)
catTree := aTree(t, withEdit(manifests, "album", ""))
app := t.TempDir()
if err := os.MkdirAll(filepath.Join(app, "modules", "album"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(app, "modules", "album", "module.json"), []byte(manifests["album"]), 0o644); err != nil {
t.Fatal(err)
}
removes := groupChange{Repository: "novox/mesh-catalog", Tree: catTree, Changed: []string{"modules/album/module.json"}}
defines := groupChange{Repository: "novox/app", Tree: app, Changed: []string{"modules/album/module.json"}}
judge := func(first groupChange, rest ...groupChange) mergeVerdict {
v, err := judgeChange(t.Context(), mergeCheckInput{facts: f, admin: os.Getenv("MESH_TEST_POSTGRES"),
repository: first.Repository, tree: first.Tree, changed: first.Changed, group: rest})
if err != nil {
t.Fatal(err)
}
return v
}
for _, v := range []mergeVerdict{judge(removes, defines), judge(defines, removes)} {
if v.Verdict == "fail" || v.Modules["album"] != "moved" {
t.Fatalf("a group moving a module machines run: %v\n%s", v.Modules, v.Report())
}
}
if v := judge(defines); v.Verdict != "fail" {
t.Errorf("a definition of a module another repository builds passed alone:\n%s", v.Report())
}
if v := judge(removes); v.Verdict != "fail" {
t.Errorf("the removal of a module machines run passed alone:\n%s", v.Report())
}
}