Files
wehub-resource-sync a06f331eb8
CI / benchmark (push) Has been skipped
install-script / posix-syntax (push) Successful in 6m1s
CI / build-onnx (push) Failing after 6m43s
init-smoke / dry-run (push) Failing after 15m57s
security / govulncheck (push) Has been cancelled
security / trivy-fs (push) Has been cancelled
CI / test (1.26, ubuntu-latest) (push) Has been cancelled
Scorecard supply-chain security / Scorecard analysis (push) Has been cancelled
CI / test (1.26, macos-latest) (push) Has been cancelled
CI / build-windows (push) Has been cancelled
CI / lint (push) Has been cancelled
install-script / powershell-syntax (push) Has been cancelled
install-script / install (macos-14) (push) Has been cancelled
install-script / install (ubuntu-latest) (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:33:42 +08:00

212 lines
5.8 KiB
Go

package main
import (
"bufio"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"github.com/spf13/cobra"
)
// exitNoAffected is the affected verb's CI sentinel: the change touched nothing
// the test graph covers, so a selective-test runner can skip the suite. It is
// distinct from the generic error exit (1) so a CI script can tell "no tests to
// run" apart from "the command failed".
const exitNoAffected = 3
var (
affectedStdin bool
affectedJSON bool
affectedQuiet bool
affectedIndex string
)
var affectedCmd = &cobra.Command{
Use: "affected [files...]",
Short: "List the test files affected by a set of changed files",
Long: `Resolve which test files cover the symbols in a set of changed files, by
walking the graph's test edges on the daemon that tracks the repo.
Built for CI / git-hook piping: pass changed paths as args or via --stdin
(e.g. ` + "`git diff --name-only | gortex affected --stdin --quiet`" + `), and
branch on the exit code:
exit 0 one or more test files are affected — run them
exit 3 nothing the test graph covers changed — skip the suite
exit 1 the command failed (no daemon, repo not tracked, …)`,
SilenceErrors: true,
SilenceUsage: true,
RunE: runAffected,
}
func init() {
affectedCmd.Flags().BoolVar(&affectedStdin, "stdin", false, "read changed file paths from stdin (whitespace/newline separated)")
affectedCmd.Flags().BoolVar(&affectedJSON, "json", false, "emit the affected test files as JSON")
affectedCmd.Flags().BoolVarP(&affectedQuiet, "quiet", "q", false, "print nothing; communicate only via the exit code")
affectedCmd.Flags().StringVar(&affectedIndex, "index", "", "repository path the daemon tracks (default: current directory)")
rootCmd.AddCommand(affectedCmd)
}
func runAffected(cmd *cobra.Command, args []string) error {
changed := append([]string(nil), args...)
if affectedStdin {
changed = append(changed, parseAffectedStdin(cmd.InOrStdin())...)
}
changed = dedupeNonEmpty(changed)
if len(changed) == 0 {
return fmt.Errorf("affected: no changed files given (pass paths as args or --stdin)")
}
repoPath := affectedIndex
if repoPath == "" {
repoPath = "."
}
targets, err := resolveAffectedTests(repoPath, changed)
if err != nil {
return err
}
if !affectedQuiet {
if affectedJSON {
_ = json.NewEncoder(cmd.OutOrStdout()).Encode(map[string]any{"affected_tests": targets})
} else {
for _, t := range targets {
fmt.Fprintln(cmd.OutOrStdout(), t)
}
}
}
if code := affectedExitCode(len(targets)); code != 0 {
return &exitCodeError{code: code}
}
return nil
}
// affectedExitCode maps an affected-test count to the CI sentinel exit code:
// 0 when something is affected (run the tests), exitNoAffected when nothing is.
func affectedExitCode(targetCount int) int {
if targetCount > 0 {
return 0
}
return exitNoAffected
}
// parseAffectedStdin reads whitespace/newline-separated file paths from r. It
// tolerates `git diff --name-only` output (one path per line) and a space-
// separated list on a single line equally.
func parseAffectedStdin(r io.Reader) []string {
var out []string
sc := bufio.NewScanner(r)
for sc.Scan() {
out = append(out, strings.Fields(sc.Text())...)
}
return out
}
// dedupeNonEmpty trims, drops blanks, and dedupes while preserving order.
func dedupeNonEmpty(in []string) []string {
seen := make(map[string]bool, len(in))
out := make([]string, 0, len(in))
for _, s := range in {
s = strings.TrimSpace(s)
if s == "" || seen[s] {
continue
}
seen[s] = true
out = append(out, s)
}
return out
}
// resolveAffectedTests maps changed files to the test files that cover them: it
// asks the daemon for each changed file's symbol IDs (get_file_summary), then
// resolves the covering tests in one get_test_targets call. The returned test
// file paths are unique and sorted.
func resolveAffectedTests(repoPath string, changedFiles []string) ([]string, error) {
idSet := make(map[string]bool)
for _, f := range changedFiles {
raw, err := requireDaemonTool(repoPath, "get_file_summary", map[string]any{"file_path": f})
if err != nil {
return nil, err
}
for _, id := range collectSymbolIDs(raw) {
idSet[id] = true
}
}
if len(idSet) == 0 {
return nil, nil // no indexed symbols in the changed files → nothing affected
}
ids := make([]string, 0, len(idSet))
for id := range idSet {
ids = append(ids, id)
}
sort.Strings(ids)
raw, err := requireDaemonTool(repoPath, "get_test_targets", map[string]any{"ids": strings.Join(ids, ",")})
if err != nil {
return nil, err
}
return parseTestTargetFiles(raw), nil
}
// parseTestTargetFiles extracts the unique, sorted test file paths from a
// get_test_targets response (its `test_targets[].file` list).
func parseTestTargetFiles(raw []byte) []string {
var resp struct {
TestTargets []struct {
File string `json:"file"`
} `json:"test_targets"`
}
if json.Unmarshal(raw, &resp) != nil {
return nil
}
seen := make(map[string]bool)
var files []string
for _, t := range resp.TestTargets {
if t.File != "" && !seen[t.File] {
seen[t.File] = true
files = append(files, t.File)
}
}
sort.Strings(files)
return files
}
// collectSymbolIDs walks a JSON document and gathers the string values under
// any "id" key that have the symbol-id shape (contain "::"), deduped. Tolerant
// of the differing get_file_summary shapes across versions.
func collectSymbolIDs(raw []byte) []string {
var doc any
if json.Unmarshal(raw, &doc) != nil {
return nil
}
seen := make(map[string]bool)
var out []string
var walk func(any)
walk = func(n any) {
switch t := n.(type) {
case map[string]any:
for k, v := range t {
if k == "id" {
if sv, ok := v.(string); ok && strings.Contains(sv, "::") && !seen[sv] {
seen[sv] = true
out = append(out, sv)
continue
}
}
walk(v)
}
case []any:
for _, e := range t {
walk(e)
}
}
}
walk(doc)
return out
}