Read a module whole while a plan has overtaken its build source, and plan every view alike (hq ADR 0267, review)

A failed build, or a plan closed before reaching a module, left the
closure its last good build said, and a fix-forward to a newly imported
package would have moved nothing. A missed merge moving only a module
that packages the repository was never caught up, and an older merge
read as history for it through a look that was not its own. The gate,
a pull request's check, the what-if and a delivery's order now read the
same view the merge handler does.
This commit is contained in:
jochen
2026-10-10 03:20:36 +02:00
parent 6c616838a5
commit 150f038ff8
9 changed files with 235 additions and 92 deletions
+90 -61
View File
@@ -314,7 +314,7 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
if err != nil {
return notNow(err)
}
read, err := readForAMerge(ctx, inv)
read, err := readForPlanning(ctx, inv)
if err != nil {
return notNow(err)
}
@@ -347,12 +347,6 @@ func (f following) SourceMoved(ctx context.Context, m link.SourceMoved) error {
m.Owner, m.Repo, m.Base, m.Commit)
return nil
}
// The same judgement for the packaging kind, against the newest look at that repository by
// anything built from it: they keep no record of it themselves, and a replayed old merge should
// not rebuild them either.
if isHistory(m.MergedAt, lastLookAt(entries, m)) {
packaging = nil
}
// Said, never silent (novox/hq 04-ISSUES/215): a module built from this repository that follows
// another branch is not part of this merge, and whoever is waiting for its change should read why.
for _, e := range entries {
@@ -571,25 +565,43 @@ func mergeCandidates(m link.SourceMoved, entries []inventory.Entry,
}
from = append(from, e)
case readsFrom(read[e.Manifest.Module], m):
// **A merge older than the module's last look is history for it** (novox/hq ADR 0267): a
// build or plan of it after the merge already read the repository with the merge in it. Per
// module, since a merge that moved only the module built from the repository says nothing
// about the ones packaging it.
if isHistory(m.MergedAt, lookedOf(read[e.Manifest.Module])) {
continue
}
packaging = append(packaging, e)
}
}
return from, packaging, already
}
// wouldMove is the modules built from the merged repository that acting on this merge would mark as
// moved and rebuild — SourceMoved's judgement, made without acting (novox/hq issue 266). Empty for a
// merge already acted on: acting marks each of them as looked at, so the merge then reads as history.
// lookedOf is when a module packaging another repository was last looked at, as readForPlanning says.
func lookedOf(read []inventory.ReadRepository) time.Time {
var at time.Time
for _, r := range read {
if r.Looked.After(at) {
at = r.Looked
}
}
return at
}
// wouldMove is the modules acting on this merge would move and rebuild — SourceMoved's judgement, made
// without acting (novox/hq issue 266). Empty for a merge already acted on: acting marks each module built
// from the repository as looked at, so the merge then reads as history for it.
//
// **Only the modules built from it, never the ones that merely package source from it.** Acting
// records nothing about those, so a merge acted on would go on reading as unacted for them, and be
// acted on again on every look. A merge that moves both is caught by the first kind, and acting on it
// rebuilds the second as well.
// **The ones packaging source from it too** (novox/hq ADR 0267): with a module moved only by the files of
// its build source, a merge can move a packaging module and nothing built from the repository, and a missed
// one of those was never acted on. A packaging module's look is its newest build or plan (lookedAt), so a
// merge acted on for it reads as history once its plan is made.
func wouldMove(m link.SourceMoved, entries []inventory.Entry,
read map[string][]inventory.ReadRepository) []inventory.Entry {
from, _, _ := mergeCandidates(m, entries, read)
from, packaging, _ := mergeCandidates(m, entries, read)
touched, _ := splitDeleted(whatTheMergeTouched(from, entries, m, read), m)
return touched
return append(touched, packaging...)
}
// splitDeleted parts the modules a merge touched into those it changed and those whose manifest it
@@ -721,75 +733,92 @@ func readsFile(e inventory.Entry, read []inventory.ReadRepository, p string) boo
return strings.Trim(e.Source.Path, "/") == "" || inside(p, e.Source.Path)
}
// pendingIn is the modules an open plan has yet to build (novox/hq ADR 0267): what such a module was built
// from is about to change, so the build source its last build said is not the one a merge now meets — an
// earlier merge that added an import to it would otherwise hide a change to what that import names. Each
// is read whole, as before, and planned again with the merge; the newer plan supersedes the older.
func pendingIn(plans []inventory.Plan) map[string]bool {
pending := map[string]bool{}
for _, p := range plans {
if !p.Open() {
continue
// staleIn is the modules whose recorded build source a plan has overtaken (novox/hq ADR 0267): a plan still
// working that has yet to build one, or a plan made after that build which never built it — failed, stopped
// or superseded. What such a module is built from is changing, or changed without a build to say so: a merge
// that added an import to it, and a later one changing only what that import names, would otherwise move
// nothing. Each is read whole, as before, until a build of it works again.
func staleIn(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string]bool {
stale := map[string]bool{}
for name, rs := range read {
var since time.Time
for _, r := range rs {
if r.Built.After(since) {
since = r.Built
}
}
for name, s := range p.Modules {
if s == nil || (s.State != "built" && s.State != planDeleted) {
pending[name] = true
for _, p := range plans {
s, in := p.Modules[name]
if !in || (s != nil && (s.State == "built" || s.State == planDeleted)) {
continue
}
if p.Open() || p.Created.After(since) {
stale[name] = true
}
}
}
return pending
return stale
}
// readForAMerge is what each module's build read, as a merge acting now maps its files onto: the build
// sources the newest trunk builds said, but for the modules an open plan has yet to build (pendingIn).
func readForAMerge(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
// lookedAt is when a merge was last acted on for a module that packages another repository's source: its
// newest build, or the newest plan that held it, whichever is later. A build asked after a merge clones that
// repository with the merge in it, so an older merge is history for it.
func lookedAt(name string, read []inventory.ReadRepository, plans []inventory.Plan) time.Time {
var at time.Time
for _, r := range read {
if r.Looked.After(at) {
at = r.Looked
}
}
for _, p := range plans {
if _, in := p.Modules[name]; in && p.Created.After(at) {
at = p.Created
}
}
return at
}
// readForPlanning is what each module's build read, as the planner maps a change onto it — for a merge
// acting now, the merge gate, a pull request's check, a delivery's order and the what-if alike, so planning
// and gating cannot disagree (novox/hq ADR 0238): the build sources the newest trunk builds said, but for
// the modules a plan has overtaken (staleIn), and with when each was last looked at (lookedAt).
func readForPlanning(ctx context.Context, inv *inventory.Inventory) (map[string][]inventory.ReadRepository, error) {
read, err := inv.ReadRepositories(ctx)
if err != nil {
return nil, err
}
open, err := inv.OpenPlans(ctx)
plans, err := inv.RecentPlans(ctx, planLookBack)
if err != nil {
return nil, err
}
return unnarrowed(read, pendingIn(open)), nil
return planningView(read, plans), nil
}
// unnarrowed is what modules read, with the build sources of the pending ones set aside: each of them is
// read whole.
func unnarrowed(read map[string][]inventory.ReadRepository, pending map[string]bool) map[string][]inventory.ReadRepository {
if len(pending) == 0 {
return read
}
// planLookBack is how many recent plans judge whether a recorded build source is overtaken.
const planLookBack = 500
// planningView is readForPlanning over what was read, so a test can hand it records.
func planningView(read map[string][]inventory.ReadRepository, plans []inventory.Plan) map[string][]inventory.ReadRepository {
stale := staleIn(read, plans)
out := make(map[string][]inventory.ReadRepository, len(read))
for name, rs := range read {
if !pending[name] {
out[name] = rs
continue
}
var whole []inventory.ReadRepository
looked := lookedAt(name, rs, plans)
var kept []inventory.ReadRepository
for _, r := range rs {
if r.Own {
continue
if stale[name] {
if r.Own {
continue
}
r.Paths = nil
}
r.Paths = nil
whole = append(whole, r)
r.Looked = looked
kept = append(kept, r)
}
out[name] = whole
out[name] = kept
}
return out
}
// lastLookAt is the most recent look at this repository by anything built from it.
func lastLookAt(entries []inventory.Entry, m link.SourceMoved) time.Time {
var newest time.Time
for _, e := range entries {
if sameRepository(e.Source.Repository, m) && e.Source.Seen.After(newest) {
newest = e.Source.Seen
}
}
return newest
}
// 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 0238). It is
// touchedBy's first answer; touchedBy is the one place the mesh maps a changed file onto its modules.