The runner asks a lint or test step of an artifact as the build seat's accepted kind step, and hears its end as the seat's step-ended on the node that ran it (ADR 0666 sections 7, 8, 21; design 47 *The steps and who serves them* line 571 and *Asked on a work queue, ended by an event* lines 583-590 at hq b229f5c4). The build modules' shared package runs a test step as a check of its one level, carries the packages whose digest the ask names kept, keeps what it passed and says the counts; a level with no tests ends nothing to run, a source fault could not run, and a transport fault or an end that could not be said is handed back unsaid, so the work queue gives the step again and it ends once under its ask's id. The neutral forms are the domain SDK's (sdk/buildstep pipelinestep.go, mesh-delivery dc0df1b5); the Go module says the end through the runtime's seat publish. Milestone 47/G, research 086 area A. Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ (Joined from 72fee3c6.) Builds: design 47 G §22, §24, §86, §116, §140 (BLD3, BLD4) Claude-Session: https://claude.ai/code/session_01JG73KEq4zoDfM9WtYpcQgZ
558 lines
20 KiB
Go
558 lines
20 KiB
Go
package buildstep
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"maps"
|
|
"path"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
|
|
sdkstep "git.novox.be/novox/mesh-delivery/sdk/buildstep"
|
|
trunk "git.novox.be/novox/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)
|
|
}
|
|
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
|
|
}
|
|
|
|
func (s AskSteps) build(ctx context.Context, job sdkstep.BuildJob) (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 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
|
|
}
|
|
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"`
|
|
}
|
|
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 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 {
|
|
return nil, 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
|
|
}
|