Skip to content

Commit e18ca06

Browse files
committed
feat(pkg): scope queries to incremental dirty targets
1 parent d1a72aa commit e18ca06

4 files changed

Lines changed: 230 additions & 7 deletions

File tree

pkg/BUILD.bazel

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ go_library(
1111
"hash_cache.go",
1212
"hash_persistence.go",
1313
"normalizer.go",
14+
"scoped_query.go",
1415
"target_determinator.go",
1516
"targets_list.go",
1617
"walker.go",
@@ -41,6 +42,7 @@ go_test(
4142
"hash_cache_test.go",
4243
"hash_persistence_test.go",
4344
"normalizer_test.go",
45+
"scoped_query_test.go",
4446
"seed_hashes_test.go",
4547
"target_determinator_test.go",
4648
"walker_test.go",

pkg/scoped_query.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package pkg
2+
3+
import (
4+
"fmt"
5+
"sort"
6+
"strings"
7+
)
8+
9+
// BuildScopedUniverse returns a bazel query expression covering the scoped
10+
// universe of a seeded run:
11+
//
12+
// - each dirty package contributes a `//pkg:all` wildcard, so that targets
13+
// added to or removed from the package since the seed are picked up
14+
// (explicit labels from the seed would miss new targets and error on
15+
// deleted ones);
16+
// - carried labels — dirty* targets in unchanged packages (i.e. reverse
17+
// deps of the dirty packages) — are named explicitly via set(). These
18+
// labels are guaranteed to still exist because their BUILD files did not
19+
// change and macro (.bzl) changes trigger a full-rehash fallback.
20+
//
21+
// The returned expression is parenthesized so it can be substituted into a
22+
// larger query expression.
23+
func BuildScopedUniverse(dirtyPackages []string, carriedLabels []string) string {
24+
terms := make([]string, 0, len(dirtyPackages)+1)
25+
for _, pkg := range dirtyPackages {
26+
if pkg == "//" {
27+
terms = append(terms, "//:all")
28+
} else {
29+
terms = append(terms, pkg+":all")
30+
}
31+
}
32+
if len(carriedLabels) > 0 {
33+
sorted := append([]string(nil), carriedLabels...)
34+
sort.Strings(sorted)
35+
terms = append(terms, "set("+strings.Join(sorted, " ")+")")
36+
}
37+
if len(terms) == 0 {
38+
return "set()"
39+
}
40+
return "(" + strings.Join(terms, " + ") + ")"
41+
}
42+
43+
// ScopeTargetsPattern narrows a targets pattern built over //... to the given
44+
// universe expression by substituting every occurrence of //... with the
45+
// universe. This preserves the semantics of filters expressed over the full
46+
// repo — e.g. `//... - attr(tags, "manual", //...)` becomes
47+
// `(U) - attr(tags, "manual", (U))` — while restricting evaluation to the
48+
// scoped universe, so manual-tag filtering behaves identically to a full run.
49+
//
50+
// Patterns that do not contain //... cannot be scoped this way; callers
51+
// should fall back to a full computation in that case.
52+
//
53+
// Known limitations of scoped mode versus a full //... query:
54+
// - --filter-incompatible-targets is a cquery-only concern; the seeded path
55+
// is only supported with --query-backend=query, which never filters
56+
// incompatible targets in full mode either.
57+
// - Targets in unchanged packages whose rule-generation depends on state
58+
// outside the package (e.g. macros) are not re-listed; any .bzl change
59+
// triggers a full-rehash fallback before reaching this point.
60+
func ScopeTargetsPattern(originalPattern string, universe string) (string, error) {
61+
var result strings.Builder
62+
replacements := 0
63+
for start := 0; start < len(originalPattern); {
64+
idx := strings.Index(originalPattern[start:], "//...")
65+
if idx < 0 {
66+
result.WriteString(originalPattern[start:])
67+
break
68+
}
69+
idx += start
70+
result.WriteString(originalPattern[start:idx])
71+
// Do not rewrite repository-qualified wildcards such as @repo//....
72+
if idx > 0 && isLabelCharacter(originalPattern[idx-1]) {
73+
result.WriteString("//...")
74+
} else {
75+
result.WriteString(universe)
76+
replacements++
77+
}
78+
start = idx + len("//...")
79+
}
80+
if replacements == 0 {
81+
return "", fmt.Errorf("targets pattern %q does not contain //... and cannot be scoped", originalPattern)
82+
}
83+
return result.String(), nil
84+
}
85+
86+
func isLabelCharacter(c byte) bool {
87+
return c == '@' || c == '_' || c == '-' || c == '.' || c == '+' ||
88+
(c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')
89+
}

pkg/scoped_query_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package pkg
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestBuildScopedUniverseEmpty(t *testing.T) {
8+
got := BuildScopedUniverse(nil, nil)
9+
if got != "set()" {
10+
t.Errorf("empty universe: want %q, got %q", "set()", got)
11+
}
12+
}
13+
14+
func TestBuildScopedUniversePackagesOnly(t *testing.T) {
15+
got := BuildScopedUniverse([]string{"//a", "//b/c"}, nil)
16+
want := "(//a:all + //b/c:all)"
17+
if got != want {
18+
t.Errorf("want %q, got %q", want, got)
19+
}
20+
}
21+
22+
func TestBuildScopedUniverseRootPackage(t *testing.T) {
23+
got := BuildScopedUniverse([]string{"//"}, nil)
24+
if got != "(//:all)" {
25+
t.Fatalf("BuildScopedUniverse(root) = %q, want %q", got, "(//:all)")
26+
}
27+
}
28+
29+
func TestBuildScopedUniverseLabelsOnly(t *testing.T) {
30+
got := BuildScopedUniverse(nil, []string{"//z:z", "//a:a"})
31+
want := "(set(//a:a //z:z))"
32+
if got != want {
33+
t.Errorf("want %q, got %q", want, got)
34+
}
35+
}
36+
37+
func TestBuildScopedUniversePackagesAndLabels(t *testing.T) {
38+
got := BuildScopedUniverse([]string{"//pkg"}, []string{"//app:binary"})
39+
want := "(//pkg:all + set(//app:binary))"
40+
if got != want {
41+
t.Errorf("want %q, got %q", want, got)
42+
}
43+
}
44+
45+
func TestScopeTargetsPatternPlain(t *testing.T) {
46+
got, err := ScopeTargetsPattern("//...", "(//pkg:all)")
47+
if err != nil {
48+
t.Fatalf("ScopeTargetsPattern: %v", err)
49+
}
50+
if got != "(//pkg:all)" {
51+
t.Errorf("want %q, got %q", "(//pkg:all)", got)
52+
}
53+
}
54+
55+
func TestScopeTargetsPatternManualFilter(t *testing.T) {
56+
original := `//... - attr(tags, "manual", //...)`
57+
got, err := ScopeTargetsPattern(original, "(//pkg:all + set(//app:binary))")
58+
if err != nil {
59+
t.Fatalf("ScopeTargetsPattern: %v", err)
60+
}
61+
want := `(//pkg:all + set(//app:binary)) - attr(tags, "manual", (//pkg:all + set(//app:binary)))`
62+
if got != want {
63+
t.Errorf("want %q, got %q", want, got)
64+
}
65+
}
66+
67+
func TestScopeTargetsPatternNoWildcard(t *testing.T) {
68+
if _, err := ScopeTargetsPattern("//app:binary", "(//pkg:all)"); err == nil {
69+
t.Fatal("expected error for pattern without //...")
70+
}
71+
}
72+
73+
func TestScopeTargetsPatternDoesNotRewriteExternalWildcard(t *testing.T) {
74+
got, err := ScopeTargetsPattern("@repo//... + //...", "(//pkg:all)")
75+
if err != nil {
76+
t.Fatal(err)
77+
}
78+
want := "@repo//... + (//pkg:all)"
79+
if got != want {
80+
t.Fatalf("ScopeTargetsPattern = %q, want %q", got, want)
81+
}
82+
}

pkg/target_determinator.go

Lines changed: 57 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1115,8 +1115,46 @@ func runToCqueryResult(context *Context, pattern string, includeTransitions bool
11151115
}
11161116
}
11171117

1118+
// maxInlineQueryPatternLength is the longest query pattern passed to bazel as
1119+
// a command-line argument. Longer patterns (e.g. scoped seeded-mode universes
1120+
// carrying many explicit labels) are written to a file and passed via
1121+
// --query_file to avoid OS argv limits.
1122+
const maxInlineQueryPatternLength = 65536
1123+
1124+
// queryPatternArgs returns the bazel query arguments encoding pattern, either
1125+
// inline or via a --query_file, along with a cleanup function.
1126+
func queryPatternArgs(pattern string) ([]string, func(), error) {
1127+
if len(pattern) <= maxInlineQueryPatternLength {
1128+
return []string{pattern}, func() {}, nil
1129+
}
1130+
queryFile, err := os.CreateTemp("", "target-determinator-query-pattern-*.txt")
1131+
if err != nil {
1132+
return nil, func() {}, fmt.Errorf("failed to create query pattern file: %w", err)
1133+
}
1134+
cleanup := func() { os.Remove(queryFile.Name()) }
1135+
if _, err := queryFile.WriteString(pattern); err != nil {
1136+
queryFile.Close()
1137+
cleanup()
1138+
return nil, func() {}, fmt.Errorf("failed to write query pattern file: %w", err)
1139+
}
1140+
if err := queryFile.Close(); err != nil {
1141+
cleanup()
1142+
return nil, func() {}, fmt.Errorf("failed to close query pattern file: %w", err)
1143+
}
1144+
return []string{"--query_file=" + queryFile.Name()}, cleanup, nil
1145+
}
1146+
1147+
// truncateForLog shortens very long query patterns for log lines.
1148+
func truncateForLog(pattern string) string {
1149+
const maxLogged = 2048
1150+
if len(pattern) <= maxLogged {
1151+
return pattern
1152+
}
1153+
return pattern[:maxLogged] + fmt.Sprintf("... (%d bytes total)", len(pattern))
1154+
}
1155+
11181156
func runToQueryResult(context *Context, pattern string) ([]*analysis.ConfiguredTarget, error) {
1119-
log.Printf("Running query on %s", pattern)
1157+
log.Printf("Running query on %s", truncateForLog(pattern))
11201158

11211159
queryOutputFile, err := os.CreateTemp("", "target-determinator-query-*.proto")
11221160
if err != nil {
@@ -1128,17 +1166,23 @@ func runToQueryResult(context *Context, pattern string) ([]*analysis.ConfiguredT
11281166
return nil, fmt.Errorf("failed to close temporary file for query output: %w", err)
11291167
}
11301168

1169+
patternArgs, patternCleanup, err := queryPatternArgs(pattern)
1170+
defer patternCleanup()
1171+
if err != nil {
1172+
return nil, err
1173+
}
1174+
11311175
var stderr bytes.Buffer
11321176
// Unlike cquery (which only gained streamed_proto and output_file in Bazel 8.2),
11331177
// bazel query has supported both since well before any Bazel version this tool targets.
11341178
returnVal, err := context.BazelCmd.Execute(
11351179
BazelCmdConfig{Dir: context.WorkspacePath, Stderr: &stderr},
11361180
[]string{"--output_base", context.BazelOutputBase},
1137-
"query", "--output=streamed_proto", "--order_output=no",
1138-
"--output_file="+queryOutput, pattern)
1181+
"query", append([]string{"--output=streamed_proto", "--order_output=no",
1182+
"--output_file=" + queryOutput}, patternArgs...)...)
11391183

11401184
if returnVal != 0 || err != nil {
1141-
return nil, fmt.Errorf("failed to run query on %s: %w. Stderr:\n%v", pattern, err, stderr.String())
1185+
return nil, fmt.Errorf("failed to run query on %s: %w. Stderr:\n%v", truncateForLog(pattern), err, stderr.String())
11421186
}
11431187

11441188
queryOutputFile, err = os.Open(queryOutput)
@@ -1167,17 +1211,23 @@ func runToQueryResult(context *Context, pattern string) ([]*analysis.ConfiguredT
11671211
}
11681212

11691213
func runToQueryLabels(context *Context, pattern string, normalizer *Normalizer) ([]label.Label, error) {
1170-
log.Printf("Running query (labels) on %s", pattern)
1214+
log.Printf("Running query (labels) on %s", truncateForLog(pattern))
11711215
var stdout bytes.Buffer
11721216
var stderr bytes.Buffer
11731217

1218+
patternArgs, patternCleanup, err := queryPatternArgs(pattern)
1219+
defer patternCleanup()
1220+
if err != nil {
1221+
return nil, err
1222+
}
1223+
11741224
returnVal, err := context.BazelCmd.Execute(
11751225
BazelCmdConfig{Dir: context.WorkspacePath, Stdout: &stdout, Stderr: &stderr},
11761226
[]string{"--output_base", context.BazelOutputBase},
1177-
"query", "--output=label", "--order_output=no", pattern)
1227+
"query", append([]string{"--output=label", "--order_output=no"}, patternArgs...)...)
11781228

11791229
if returnVal != 0 || err != nil {
1180-
return nil, fmt.Errorf("failed to run query on %s: %w. Stderr:\n%v", pattern, err, stderr.String())
1230+
return nil, fmt.Errorf("failed to run query on %s: %w. Stderr:\n%v", truncateForLog(pattern), err, stderr.String())
11811231
}
11821232

11831233
var labels []label.Label

0 commit comments

Comments
 (0)