The ask carried the reserved version (§573), but the build modules' shared run gave each artifact's build only the artifact's own version, which no asker sets, so no build got MESH_VERSION and a Go package was packed as a candidate and never published. The run now gives every build the ask's version, and the builder names the registry seat each artifact of a versioned build is published through, so a Go package is published under its reserved version on go-package-registry. Builds: design 47 G §370, §573, §458; design 51 *Publishing* Claude-Session: https://claude.ai/code/session_01NcbgfGKaxw984XyAPfLm4h
572 lines
21 KiB
Go
572 lines
21 KiB
Go
package steprun
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
sdkstep "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/buildstep"
|
|
trunk "git.novox.be/novox/mesh-delivery/modules/mesh-delivery-sdk/trunk"
|
|
)
|
|
|
|
// **One ask, one whole job** (novox/hq ADR 0525 §3). A build module runs a pulled ask with its own step tools' bodies,
|
|
// the same ones its request and reply tools run (ADR 0525 §8), in the order the job names: a build ask fetches, then
|
|
// builds or copies each artifact; a check ask fetches, digests the level, takes the kept verdicts and tests the rest,
|
|
// the packages whose tests the change touches first. The answers are the tools' own, whole, so the asker judges them as
|
|
// it judged the tools' answers.
|
|
|
|
// StepFunc is one step tool's body: its arguments in, its answer out.
|
|
type StepFunc func(ctx context.Context, args map[string]any) (any, error)
|
|
|
|
// AskSteps are the step tools a build module runs an ask with. Copy is the image module's alone; Digest the Go module's.
|
|
type AskSteps struct {
|
|
Fetch, Build, Copy, Digest, Test StepFunc
|
|
// Imports reads the mesh packages the built programs import (the Go module's alone, ADR 0593 §2): it answers
|
|
// {"imports": [...]}, sorted, from the workspace the build ran in. A build that built an artifact asks it once, at
|
|
// its end; nil reports none.
|
|
Imports StepFunc
|
|
// Compare regenerates a contracts module's generated files (a package artifact naming generated-from) and answers
|
|
// {"out-of-step": [...]} (CompareStep): a build ask runs it once on the fetched workspace and answers the paths in
|
|
// its out-of-step, a failed build's too, so a trunk build's end names them where no test step runs. Nil compares none.
|
|
Compare StepFunc
|
|
// Toolchain is the toolchain the module builds with now; a build ask naming another is declined (Declining). Nil checks nothing
|
|
// (an image or an archive names none).
|
|
Toolchain func(ctx context.Context) (sdkstep.ToolchainUsed, error)
|
|
// Kept looks up a kept verdict by level and digest (the checker's kept bucket, the checker seat's kept key): whether one is
|
|
// kept and how many tests it counted. Nil takes none.
|
|
Kept func(ctx context.Context, level, digest string) (tests int, found bool, err error)
|
|
// Release removes a workspace once the ask's steps on it ended (the module's release tool's body); nil leaves it to
|
|
// the module's sweep.
|
|
Release func(workspace string) error
|
|
Logf func(format string, args ...any)
|
|
// Has says whether this node has a need a package's tests declare (sdkstep.AskNeeds, issue 890); a package needing
|
|
// what it lacks is not tested here but named in the level's Unmet, for the asker to place. Nil has nothing.
|
|
Has func(need string) bool
|
|
// Lint is the module's lint body for a pipeline step lint (novox/hq ADR 0666 §7): it takes the workspace and the
|
|
// module's directory and answers a LintAnswer. Nil ends every lint step as nothing to run.
|
|
Lint StepFunc
|
|
// Node is the node this holder runs on, which a step's end names; SayStep says a pipeline step's end as the seat's
|
|
// event step-ended on that node's subject (sdkstep.StepEndedSubject), under the ask's id. Nil runs no step ask.
|
|
Node string
|
|
SayStep func(end sdkstep.StepEnded) error
|
|
}
|
|
|
|
// RunAsk runs ask's job with steps and answers a sdkstep.BuildAnswer or a sdkstep.CheckAnswer.
|
|
func RunAsk(ctx context.Context, steps AskSteps, ask sdkstep.StepAsk) (any, error) {
|
|
if ask.Build != nil {
|
|
job := *ask.Build
|
|
job.Fetch = withPackages(job.Fetch, ask.Packages)
|
|
return steps.build(ctx, job, ask.Version)
|
|
}
|
|
if ask.Step != nil {
|
|
return steps.runStep(ctx, ask)
|
|
}
|
|
if ask.Check != nil {
|
|
job := *ask.Check
|
|
job.Fetch = *withPackages(&job.Fetch, ask.Packages)
|
|
return steps.check(ctx, job)
|
|
}
|
|
return nil, Refuse("the ask %s holds no job", ask.ID)
|
|
}
|
|
|
|
// withPackages is the fetch's arguments with the ask's package versions (novox/hq ADR 0573 §2), which the build module
|
|
// writes into the workspace it fetches; a copy, so the ask is left as it was. An ask with none, or a copy alone that
|
|
// fetches nothing, is left as it is.
|
|
func withPackages(fetch *sdkstep.Fetch, packages sdkstep.PackageVersions) *sdkstep.Fetch {
|
|
if len(packages) == 0 || fetch == nil {
|
|
return fetch
|
|
}
|
|
out := *fetch
|
|
out.Packages = maps.Clone(packages)
|
|
return &out
|
|
}
|
|
|
|
// FetchArgs is a fetch as the fetch tool takes it (sdkstep.FetchRequest's Args and the build module's own, beside): the
|
|
// ask's fetch through the one JSON form, without the layout version, which no tool takes.
|
|
func FetchArgs(f sdkstep.Fetch) (map[string]any, error) {
|
|
b, err := json.Marshal(f)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
args := map[string]any{}
|
|
if err := json.Unmarshal(b, &args); err != nil {
|
|
return nil, err
|
|
}
|
|
delete(args, "v")
|
|
return args, nil
|
|
}
|
|
|
|
func raw(v any) (json.RawMessage, error) {
|
|
if r, ok := v.(json.RawMessage); ok {
|
|
return r, nil
|
|
}
|
|
return json.Marshal(v)
|
|
}
|
|
|
|
// fetched is what an ask reads of a fetch's answer.
|
|
type fetched struct {
|
|
Workspace string `json:"workspace"`
|
|
Merge string `json:"merge"`
|
|
Conflicts []string `json:"conflicts"`
|
|
Changed []string `json:"changed"`
|
|
Modules []string `json:"modules"`
|
|
}
|
|
|
|
func (s AskSteps) fetch(ctx context.Context, fetch *sdkstep.Fetch) (json.RawMessage, fetched, error) {
|
|
if s.Fetch == nil {
|
|
return nil, fetched{}, Refuse("this build module fetches nothing")
|
|
}
|
|
if fetch == nil {
|
|
return nil, fetched{}, Refuse("the ask names no fetch")
|
|
}
|
|
args, err := FetchArgs(*fetch)
|
|
if err != nil {
|
|
return nil, fetched{}, err
|
|
}
|
|
if fetch.Base != "" {
|
|
Phase(ctx, "fetch and merge")
|
|
} else {
|
|
Phase(ctx, "fetch")
|
|
}
|
|
out, err := s.Fetch(ctx, args)
|
|
if err != nil {
|
|
return nil, fetched{}, err
|
|
}
|
|
r, err := raw(out)
|
|
if err != nil {
|
|
return nil, fetched{}, err
|
|
}
|
|
var f fetched
|
|
if err := json.Unmarshal(r, &f); err != nil || f.Workspace == "" {
|
|
return r, f, fmt.Errorf("the fetch answered no workspace: %s", r)
|
|
}
|
|
return r, f, nil
|
|
}
|
|
|
|
// onToolchain is the toolchain this node builds with when the ask names one and this module reports its own, nil when
|
|
// either is absent. A node on another toolchain than the ask names answers a Declining: it is not on the agreed one and
|
|
// sits out, for a node that is to take the ask (novox/hq ADR 0584 clause 2).
|
|
func (s AskSteps) onToolchain(ctx context.Context, want *sdkstep.ToolchainUsed) (*sdkstep.ToolchainUsed, error) {
|
|
if want == nil || s.Toolchain == nil {
|
|
return nil, nil
|
|
}
|
|
tc, err := s.Toolchain(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if tc.Version != want.Version || tc.Recipe != want.Recipe {
|
|
return nil, &Declining{Lacks: []string{fmt.Sprintf("the agreed toolchain %s (recipe %s)", want.Version, want.Recipe)},
|
|
Text: fmt.Sprintf("the ask is for %s (recipe %s) and this node builds with %s (recipe %s): it sits out, and the ask is left to a node on the agreed toolchain",
|
|
want.Version, want.Recipe, tc.Version, tc.Recipe)}
|
|
}
|
|
return &tc, nil
|
|
}
|
|
|
|
// build runs a build job; version is the version the ask carries (design 47 G §573), which each artifact's build is given
|
|
// as its argument version, the build's MESH_VERSION (§370), unless the artifact names its own.
|
|
func (s AskSteps) build(ctx context.Context, job sdkstep.BuildJob, version string) (any, error) {
|
|
answer := sdkstep.BuildAnswer{}
|
|
tc, err := s.onToolchain(ctx, job.Toolchain)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
answer.Toolchain = tc
|
|
var f fetched
|
|
defer func() { s.release(f.Workspace) }()
|
|
for _, a := range job.Artifacts {
|
|
if a.From == "" && f.Workspace == "" {
|
|
r, got, err := s.fetch(ctx, job.Fetch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if job.Fetch.Base != "" {
|
|
// A candidate is built from its head merged onto the trunk; a head that does not merge leaves the tree at the
|
|
// trunk, and building that would store the trunk's build under the candidate's fingerprint.
|
|
if len(got.Conflicts) > 0 {
|
|
return nil, fmt.Errorf("the head does not merge into the trunk: files in conflict: %s", strings.Join(got.Conflicts, ", "))
|
|
}
|
|
if got.Merge == "" {
|
|
return nil, fmt.Errorf("the fetch with a base answered no merge: %s", r)
|
|
}
|
|
}
|
|
f = got
|
|
if s.Compare != nil {
|
|
Phase(ctx, "compare the generated files")
|
|
out, err := s.Compare(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the generated files of %s: %w", job.Module, err)
|
|
}
|
|
r, err := raw(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var read struct {
|
|
OutOfStep []string `json:"out-of-step"`
|
|
}
|
|
if err := json.Unmarshal(r, &read); err != nil {
|
|
return nil, fmt.Errorf("the compare of %s answered what is not a list: %s", job.Module, r)
|
|
}
|
|
answer.OutOfStep = read.OutOfStep
|
|
}
|
|
}
|
|
var (
|
|
out any
|
|
err error
|
|
)
|
|
if a.From != "" {
|
|
if s.Copy == nil {
|
|
return nil, Refuse("this build module copies no artifact (%s)", a.Artifact)
|
|
}
|
|
Phase(ctx, "copy "+a.Artifact)
|
|
out, err = s.Copy(ctx, map[string]any{"module": job.Module, "artifact": a.Artifact, "from": a.From})
|
|
} else {
|
|
if s.Build == nil {
|
|
return nil, Refuse("this build module builds no artifact (%s)", a.Artifact)
|
|
}
|
|
args := map[string]any{"workspace": f.Workspace, "module": job.Path, "artifact": a.Artifact}
|
|
if job.Publish {
|
|
args["publish"] = true
|
|
}
|
|
// The fingerprint a build kept for the artifact store's holder is asked by (kept-artifact, milestone 47/G W12.5).
|
|
if job.Fp != "" {
|
|
args["fp"] = job.Fp
|
|
}
|
|
// The seat it is published through and the version the builder gave it (design 51 *Publishing*, CAT-BG2).
|
|
if a.Seat != "" {
|
|
args["seat"] = a.Seat
|
|
}
|
|
if a.Version != "" {
|
|
args["version"] = a.Version
|
|
} else if version != "" {
|
|
args["version"] = version
|
|
}
|
|
Phase(ctx, "build "+a.Artifact)
|
|
out, err = s.Build(ctx, args)
|
|
}
|
|
if err != nil {
|
|
// The compare's out-of-step is answered with the failure, so the build's end still names it.
|
|
if len(answer.OutOfStep) > 0 {
|
|
return answer, fmt.Errorf("%s: %w", a.Artifact, err)
|
|
}
|
|
return nil, fmt.Errorf("%s: %w", a.Artifact, err)
|
|
}
|
|
r, err := raw(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var built struct {
|
|
Digest string `json:"digest"`
|
|
Reference string `json:"reference"`
|
|
Kept string `json:"kept"`
|
|
}
|
|
if err := json.Unmarshal(r, &built); err != nil || built.Digest == "" {
|
|
return nil, fmt.Errorf("the build of %s answered no digest: %s", a.Artifact, r)
|
|
}
|
|
answer.Artifacts = append(answer.Artifacts, sdkstep.AskBuilt{Artifact: a.Artifact, Digest: built.Digest, Reference: built.Reference})
|
|
if built.Kept != "" {
|
|
answer.Kept = strings.TrimPrefix(built.Kept, "sha256:")
|
|
}
|
|
}
|
|
if s.Imports != nil && f.Workspace != "" {
|
|
out, err := s.Imports(ctx, map[string]any{"workspace": f.Workspace, "module": job.Path})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the imports of %s: %w", job.Module, err)
|
|
}
|
|
r, err := raw(out)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var read struct {
|
|
Imports []string `json:"imports"`
|
|
}
|
|
if err := json.Unmarshal(r, &read); err != nil {
|
|
return nil, fmt.Errorf("the imports of %s answered what is not a list: %s", job.Module, r)
|
|
}
|
|
if err := trunk.CheckImports(read.Imports); err != nil {
|
|
return nil, err
|
|
}
|
|
answer.Imports = read.Imports
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
func (s AskSteps) check(ctx context.Context, job sdkstep.CheckJob) (any, error) {
|
|
if _, err := s.onToolchain(ctx, job.Toolchain); err != nil {
|
|
return nil, err
|
|
}
|
|
r, f, err := s.fetch(ctx, &job.Fetch)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer s.release(f.Workspace)
|
|
answer := sdkstep.CheckAnswer{}
|
|
if err := json.Unmarshal(r, &answer.Fetch); err != nil {
|
|
return nil, fmt.Errorf("the fetch answered what is not a fetch answer: %s: %w", r, err)
|
|
}
|
|
if len(f.Conflicts) > 0 {
|
|
return answer, nil // the asker reads the conflict from the fetch's answer: a verdict, not an error
|
|
}
|
|
if s.Test == nil {
|
|
return nil, Refuse("this build module tests nothing")
|
|
}
|
|
runner := ""
|
|
if job.Runner != "" && (job.RunnerWhen == "" || changesUnder(f.Changed, job.RunnerWhen)) {
|
|
runner = job.Runner
|
|
}
|
|
// Every level runs, also after one that did not pass: one check names every failure, not only the first (the operator,
|
|
// 2026-11-02: "A failed check shows only its first fault").
|
|
for _, level := range job.Levels {
|
|
la := sdkstep.LevelAnswer{Level: level}
|
|
_, err := s.level(ctx, job, level, f, runner, &la)
|
|
answer.Levels = append(answer.Levels, la)
|
|
if err != nil {
|
|
// The fetch merged: its answer is kept on the failed step's end, so the checker previews the merge and judges its
|
|
// manifests whatever the level ended with (design 47 G A-286).
|
|
return answer, err
|
|
}
|
|
}
|
|
return answer, nil
|
|
}
|
|
|
|
// level runs one level of a check ask and says whether it passed.
|
|
func (s AskSteps) level(ctx context.Context, job sdkstep.CheckJob, level string, f fetched, runner string, la *sdkstep.LevelAnswer) (bool, error) {
|
|
withRunner := func(args map[string]any) map[string]any {
|
|
if runner != "" {
|
|
args["runner"] = runner
|
|
}
|
|
if len(job.NodeNames) > 0 {
|
|
args[sdkstep.NodeNamesArg] = job.NodeNames // the names check's (ADR 0567 §12)
|
|
}
|
|
return args
|
|
}
|
|
test := func(args map[string]any) (bool, error) {
|
|
what := "the whole level"
|
|
if dir, _ := args["module"].(string); dir != "" {
|
|
what = dir
|
|
} else if pkgs, _ := args["packages"].([]string); len(pkgs) > 0 {
|
|
what = fmt.Sprintf("%d packages", len(pkgs))
|
|
}
|
|
Phase(ctx, fmt.Sprintf("%s: tests of %s", level, what))
|
|
out, err := s.Test(ctx, args)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
r, err := raw(out)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
la.Tests = append(la.Tests, r)
|
|
var t struct {
|
|
Passed bool `json:"passed"`
|
|
}
|
|
_ = json.Unmarshal(r, &t)
|
|
return t.Passed, nil
|
|
}
|
|
// The packages (or Node directories) whose tests the change touches run first (issue 1289; ADR 0444), and then every
|
|
// other test step of the level, also after one that did not pass, so one check names every failing test of the level
|
|
// and not only the first (issue 1921; the operator, 2026-11-02); the level passes only when each of its steps did.
|
|
all := true
|
|
if s.Digest == nil {
|
|
// A module with no digest (Node) tests each module directory of the tree, those whose tests changed first.
|
|
for _, priority := range []bool{true, false} {
|
|
for _, dir := range f.Modules {
|
|
if changesTestIn(f.Changed, dir) != priority {
|
|
continue
|
|
}
|
|
passed, err := test(withRunner(map[string]any{"workspace": f.Workspace, "module": dir, "level": level}))
|
|
if err != nil {
|
|
return false, fmt.Errorf("the %s level of %s: %w", level, dir, err)
|
|
}
|
|
all = all && passed
|
|
}
|
|
}
|
|
return all, nil
|
|
}
|
|
todo, whole, keepRunner := s.plan(ctx, job, level, f, runner, withRunner, la)
|
|
if !keepRunner {
|
|
runner = "" // an installed module that ignores the runner judges the level as it judges any other (ADR 0535 §2)
|
|
}
|
|
if whole && len(job.Packages) > 0 {
|
|
todo, whole = job.Packages, false // a placed ask tests its packages alone, whatever the digest said (ADR 0552 §4)
|
|
}
|
|
for _, priority := range []bool{true, false} {
|
|
args := withRunner(map[string]any{"workspace": f.Workspace, "level": level})
|
|
if job.Run != "" {
|
|
args["run"] = job.Run
|
|
}
|
|
if whole {
|
|
if priority {
|
|
continue // nothing to put first: the level runs whole after
|
|
}
|
|
} else {
|
|
var run []string
|
|
for _, p := range todo {
|
|
if changesTestOf(f.Changed, p) == priority {
|
|
run = append(run, p)
|
|
}
|
|
}
|
|
if len(run) == 0 {
|
|
continue
|
|
}
|
|
args["packages"] = run
|
|
}
|
|
passed, err := test(args)
|
|
if err != nil {
|
|
return false, fmt.Errorf("the %s level: %w", level, err)
|
|
}
|
|
all = all && passed // the others still run after a failure (issue 1921)
|
|
}
|
|
return all, nil
|
|
}
|
|
|
|
// release removes an ask's workspace, saying a failure: the module's sweep removes it later.
|
|
func (s AskSteps) release(workspace string) {
|
|
if workspace == "" || s.Release == nil {
|
|
return
|
|
}
|
|
if err := s.Release(workspace); err != nil && s.Logf != nil {
|
|
s.Logf("the workspace %s is left to the sweep: %v", workspace, err)
|
|
}
|
|
}
|
|
|
|
// plan digests the level and takes the kept verdicts: the packages left to test, or all when the level runs whole, and
|
|
// whether the runner stays. A try of some tests runs whole, digesting nothing (a partial run proves no package). Under the
|
|
// candidate's runner the digest is asked as the candidate: when the candidate answers it, every package runs and nothing
|
|
// is digested for keeping (ADR 0535 §4); when the installed module answers it (it does not take the runner), the level is
|
|
// planned as any other and the runner is dropped. The level runs whole and takes nothing when the digest names no package
|
|
// or does not cover the siblings the fetch placed (ADR 0444, L5).
|
|
func (s AskSteps) plan(ctx context.Context, job sdkstep.CheckJob, level string, f fetched, runner string, withRunner func(map[string]any) map[string]any, la *sdkstep.LevelAnswer) ([]string, bool, bool) {
|
|
if job.Run != "" {
|
|
return nil, true, true
|
|
}
|
|
Phase(ctx, level+": digest")
|
|
out, err := s.Digest(ctx, withRunner(map[string]any{"workspace": f.Workspace, "level": level}))
|
|
if err != nil {
|
|
if s.Logf != nil {
|
|
s.Logf("no digest of the %s level, so it runs whole: %v", level, err)
|
|
}
|
|
return nil, true, true
|
|
}
|
|
r, err := raw(out)
|
|
if err != nil {
|
|
return nil, true, true
|
|
}
|
|
var d sdkstep.LevelDigest // packages kept as a sorted list, never keyed by a package path (issue 1930)
|
|
if err := json.Unmarshal(r, &d); err != nil {
|
|
return nil, true, true
|
|
}
|
|
if runner != "" {
|
|
if strings.HasPrefix(d.JudgedBy, "candidate ") {
|
|
la.Judged = d.JudgedBy
|
|
return nil, true, true // the candidate decides which verdicts would be taken: all run, nothing kept
|
|
}
|
|
runner = ""
|
|
}
|
|
if len(d.Packages) == 0 || !slices.Equal(d.Beside, besideOf(job.Fetch)) {
|
|
return nil, true, runner != "" // a digest that names nothing or not the siblings keys no verdict: the level runs whole
|
|
}
|
|
la.Digest = &d
|
|
only := map[string]bool{}
|
|
for _, p := range job.Packages {
|
|
only[p] = true
|
|
}
|
|
var todo []string
|
|
for _, pd := range d.Packages {
|
|
p := pd.Package
|
|
if len(only) > 0 && !only[p] {
|
|
continue // a placed ask's other packages were judged by the first ask (ADR 0552 §4)
|
|
}
|
|
if job.Kept && s.Kept != nil {
|
|
tests, found, err := s.Kept(ctx, level, pd.Digest)
|
|
if err != nil && s.Logf != nil {
|
|
s.Logf("the kept verdict of %s at %s cannot be read, so it runs: %v", p, level, err)
|
|
}
|
|
if err == nil && found {
|
|
la.Kept = append(la.Kept, sdkstep.KeptTaken{Package: p, Digest: pd.Digest, Tests: tests})
|
|
continue
|
|
}
|
|
}
|
|
if needs := d.NeedsOf(p); len(sdkstep.Lacks(needs, s.Has)) > 0 {
|
|
// Its tests would only skip here (issue 890): it is left for a node that has what they need.
|
|
la.Unmet = append(la.Unmet, sdkstep.PackageNeeds{Package: p, Needs: needs})
|
|
if s.Logf != nil {
|
|
s.Logf("the %s level of %s is left to a node with %s: this node lacks %s", level, p,
|
|
strings.Join(needs, ", "), strings.Join(sdkstep.Lacks(needs, s.Has), ", "))
|
|
}
|
|
continue
|
|
}
|
|
todo = append(todo, p)
|
|
}
|
|
return todo, false, false
|
|
}
|
|
|
|
// besideOf are the siblings a fetch's arguments place, as a digest echoes them: `<owner/repo> <commit>`, sorted.
|
|
func besideOf(fetch sdkstep.Fetch) []string {
|
|
var out []string
|
|
for _, r := range fetch.Beside {
|
|
out = append(out, r.Repository+" "+r.Commit)
|
|
}
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// changesUnder says whether a path the merge changes is under prefix.
|
|
func changesUnder(changed []string, prefix string) bool {
|
|
for _, p := range changed {
|
|
if strings.HasPrefix(p, prefix) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// isTestFile says whether a repository-relative path is a test file: Go's _test.go, and a TypeScript module's .test.
|
|
// and .spec. files and test directories.
|
|
func isTestFile(p string) bool {
|
|
switch {
|
|
case strings.HasSuffix(p, "_test.go"):
|
|
return true
|
|
case strings.Contains(p, ".test.") || strings.Contains(p, ".spec."):
|
|
return true
|
|
}
|
|
for _, seg := range strings.Split(path.Dir(p), "/") {
|
|
if seg == "__tests__" || seg == "test" || seg == "tests" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// changesTestOf says whether the merge changes a test file of the Go package with the import path pkg: the file's
|
|
// directory and the import path agree on their last segments.
|
|
func changesTestOf(changed []string, pkg string) bool {
|
|
for _, f := range changed {
|
|
if !strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
dir := path.Dir(f)
|
|
if dir == pkg || strings.HasSuffix(pkg, "/"+dir) || strings.HasSuffix(dir, "/"+pkg) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// changesTestIn says whether the merge changes a test file inside the directory of a Node module.
|
|
func changesTestIn(changed []string, dir string) bool {
|
|
for _, f := range changed {
|
|
if !isTestFile(f) || strings.HasSuffix(f, "_test.go") {
|
|
continue
|
|
}
|
|
if dir == "." || dir == "" || strings.HasPrefix(f, strings.TrimSuffix(dir, "/")+"/") {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|