diff --git a/CHANGELOG.md b/CHANGELOG.md index f4c58b0..60a7173 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to Diffsmith are documented here. Format follows `docs/dev-plan/release-plan.md` § Release Notes Shape; versioning is Semantic Versioning per the same doc. +## Unreleased + +### Added + +- `--include ` flag (repeatable, on `review`, `inbox`, and + bare `diffsmith`): the allowlist counterpart of `--exclude`. Keeps + only files matching at least one pattern and drops the rest before + the prompt is built, for reviews that should focus on one area of a + large diff. Same gitignore-lite rules as `--exclude` (trailing `/` = + directory tree at any depth; no `/` = basename glob; otherwise + full-path glob), and renames are kept when either side matches. + `--include` runs first, then `--exclude` carves exceptions out of the + kept set (`--include 'internal/' --exclude 'internal/gen/'`). The + narrowing is surfaced in the run summary (or stderr for + `--print-prompt`/`--dry-run`), malformed globs fail up front, and an + `--include` that matches no changed file is a clean error before any + model call. The adapters' over-budget hint now names both flags. + ## v0.2.2 — 2026-06-12 ### Added diff --git a/README.md b/README.md index 435815f..89cbdf2 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ To sharpen findings, diffsmith also sends the PR/MR description and the acceptan Large diffs can exceed the per-model input budget (1 MiB by default). Pass `--exclude ` (repeatable) to drop noise files from the review — lockfiles, vendored deps, generated code — before the prompt is built: a trailing `/` excludes a directory tree at any depth (`vendor/`), a pattern without `/` matches basenames anywhere (`*.lock`), anything else is a full-path glob (`internal/gen/*.go`). Exclusions are surfaced in the run summary, never silent, and excluding every changed file is an error rather than an empty review. +`--include ` (repeatable) is the allowlist counterpart: it keeps only the matching files and drops the rest, using the same pattern rules. It runs first, then `--exclude` carves exceptions out of the kept set — so `--include 'internal/' --exclude 'internal/gen/'` reviews everything under `internal/` except the generated tree. Like exclusions, the narrowing is surfaced in the run summary, and an `--include` that matches no changed file is an error rather than an empty review. + After review, `p` in the TUI marks findings for upstream posting. On quit, diffsmith asks for explicit `y` confirmation, then posts approved findings as inline review threads on the PR/MR. Findings whose `(file, line)` already has a diffsmith thread upstream are skipped with a summary line; pass `--repost` to bypass that dedup gate. ## Install diff --git a/internal/app/review.go b/internal/app/review.go index 5a6815e..2bd6672 100644 --- a/internal/app/review.go +++ b/internal/app/review.go @@ -90,9 +90,36 @@ func registerModelFlowFlags(cmd *cobra.Command, flags *reviewFlags) { cmd.Flags().IntVar(&flags.inputBudget, "input-budget", 0, "override the per-adapter prompt-size cap in bytes (default: 1 MiB per adapter; 0 keeps the default)") cmd.Flags().DurationVar(&flags.modelTimeout, "model-timeout", 10*time.Minute, "per-model wall-clock cap; a model exceeding it is cancelled and dropped from the review (0 disables)") cmd.Flags().BoolVar(&flags.noContext, "no-context", false, "do not send the PR/MR description or fetch linked-issue acceptance criteria to the model (diff-only review)") + cmd.Flags().StringArrayVar(&flags.include, "include", nil, "review only files matching these patterns (repeatable); applied before --exclude, which then carves exceptions out of the kept set. Same pattern rules as --exclude: trailing '/' = directory tree at any depth ('internal/'); no '/' = basename glob ('*.go'); otherwise full-path glob ('internal/app/*.go')") cmd.Flags().StringArrayVar(&flags.exclude, "exclude", nil, "exclude files from the review diff (repeatable). Patterns: trailing '/' = directory tree at any depth ('vendor/'); no '/' = basename glob ('*.lock'); otherwise full-path glob ('internal/gen/*.go')") } +// applyIncludes narrows the fetched input to files matching --include, +// mutating it in place. It is the allowlist counterpart of applyExcludes +// and runs BEFORE it (see runReviewByURL) so --exclude carves exceptions +// out of the kept set — the gitignore precedence the flag help promises. +// The returned note ("" when --include was absent or matched everything) +// must reach the user so the narrowing is never silent. Matching nothing +// is an error: the review would be vacuous and a model call a waste. +func applyIncludes(input *review.ReviewInput, patterns []string) (string, error) { + total := len(input.Files) + origRawLen := len(input.RawDiff) + kept, keptRaw, dropped, err := diff.Include(input.Files, input.RawDiff, patterns) + if err != nil { + return "", err + } + if len(dropped) == 0 { + // No patterns, or every file matched: nothing was narrowed away. + return "", nil + } + if len(kept) == 0 { + return "", fmt.Errorf("no changed file(s) matched --include; nothing left to review") + } + input.Files, input.RawDiff = kept, keptRaw + return fmt.Sprintf("--include kept %d of %d changed file(s), dropped %d bytes of diff", + len(kept), total, origRawLen-len(keptRaw)), nil +} + // applyExcludes filters the fetched input per --exclude, mutating it in // place. The returned note ("" when nothing was excluded) must reach the // user — run summary on the TUI path, stderr on the bypass path — so @@ -139,6 +166,11 @@ func renameMapFromFiles(files []*diff.DiffFile) map[string]string { type reviewFlags struct { dryRun bool printPrompt bool + // include holds --include patterns applied to the fetched diff + // before --exclude (and before context enrichment / prompt build), + // on every entry point. Empty means "review everything". See + // applyIncludes for semantics. + include []string // exclude holds --exclude patterns applied to the fetched diff // before context enrichment and prompt build, on every entry // point. See applyExcludes for semantics. @@ -250,7 +282,15 @@ func runReviewByURL(ctx context.Context, cmd *cobra.Command, url string, flags * return err } // Filter before enrichment so --print-prompt and --dry-run - // reflect exactly what a real run would send. + // reflect exactly what a real run would send. --include narrows + // first, then --exclude carves exceptions out of the kept set. + includeNote, err := applyIncludes(input, flags.include) + if err != nil { + return err + } + if includeNote != "" { + fmt.Fprintf(cmd.ErrOrStderr(), "diffsmith: %s\n", includeNote) + } excludeNote, err := applyExcludes(input, flags.exclude) if err != nil { return err @@ -334,11 +374,18 @@ func runReviewByURL(ctx context.Context, cmd *cobra.Command, url string, flags * return } - // --exclude runs before enrichment and prompt build. Unlike - // context enrichment this IS fatal on error: a bad pattern or - // an everything-excluded result means the user's filter intent - // can't be honored, and reviewing the unfiltered diff anyway - // would silently ignore it. + // --include then --exclude run before enrichment and prompt + // build. Unlike context enrichment these ARE fatal on error: a + // bad pattern, a nothing-included, or an everything-excluded + // result means the user's filter intent can't be honored, and + // reviewing the unfiltered diff anyway would silently ignore it. + // --include narrows first; --exclude carves exceptions out of + // the kept set. + includeNote, includeErr := applyIncludes(input, flags.include) + if includeErr != nil { + send(tui.LoadErrorMsg{Err: includeErr}) + return + } excludeNote, excludeErr := applyExcludes(input, flags.exclude) if excludeErr != nil { send(tui.LoadErrorMsg{Err: excludeErr}) @@ -353,9 +400,15 @@ func runReviewByURL(ctx context.Context, cmd *cobra.Command, url string, flags * send(tui.PhaseStatusMsg("Fetching PR/issue context…")) } contextNotes := enrichWithContext(ctx, fetcher, input, flags.noContext) + // Prepend filter notes so they lead the summary, in the order + // the filters ran: --include first, then --exclude. (Prepend + // exclude, then include, so include lands at the front.) if excludeNote != "" { contextNotes = append([]string{excludeNote}, contextNotes...) } + if includeNote != "" { + contextNotes = append([]string{includeNote}, contextNotes...) + } send(tui.PhaseStatusMsg("Reviewing with selected models…")) outcomes := runModelsInParallel(ctx, selected.All, input, send, flags.modelTimeout) diff --git a/internal/app/review_test.go b/internal/app/review_test.go index 43f2bd1..0e02a4d 100644 --- a/internal/app/review_test.go +++ b/internal/app/review_test.go @@ -1111,6 +1111,132 @@ func TestReviewPipelineExcludeNoteReachesRunSummary(t *testing.T) { } } +// TestReviewDryRunAppliesInclude: --include must narrow the diff on the +// bypass path (dry-run / print-prompt) too, and surface the narrowing so +// it is never silent. +func TestReviewDryRunAppliesInclude(t *testing.T) { + stub := &stubProvider{ + supports: func(string) bool { return true }, + fetchInput: reviewInputWithTwoFileDiff(t), + } + root, out := newTestRoot(stub) + root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--dry-run", "--include", "*.go"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + got := out.String() + if !strings.Contains(got, "fetched 1 file(s)") { + t.Errorf("dry-run should count post-include files; got:\n%s", got) + } + if !strings.Contains(got, "--include kept 1 of 2") { + t.Errorf("narrowing must be surfaced, never silent; got:\n%s", got) + } +} + +func TestReviewPrintPromptAppliesInclude(t *testing.T) { + stub := &stubProvider{ + supports: func(string) bool { return true }, + fetchInput: reviewInputWithTwoFileDiff(t), + } + root, out := newTestRoot(stub) + root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--print-prompt", "--include", "*.go"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + got := out.String() + if strings.Contains(got, "package-lock.json") { + t.Error("non-included file leaked into the printed prompt") + } + if !strings.Contains(got, "auth/session.go") { + t.Error("included file missing from the printed prompt") + } +} + +// TestReviewNoFilesIncludedFailsBeforeModelCall: an --include that +// matches nothing is a clean pre-model error naming the flag, not an +// empty review or a model call on an empty diff. +func TestReviewNoFilesIncludedFailsBeforeModelCall(t *testing.T) { + stubProv := &stubProvider{ + supports: func(string) bool { return true }, + fetchInput: reviewInputWithTwoFileDiff(t), + } + mockModel := &stubModel{name: "codex", reviewResult: &review.ModelReviewResult{Model: "codex"}} + withFakePicker(t, map[string]model.Model{"codex": mockModel}) + withFakeTUI(t, func(*tui.Model) error { return nil }) + + root, _ := newTestRootWithModels(stubProv, map[string]model.Model{"codex": mockModel}) + root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--include", "*.rs"}) + + err := root.Execute() + if err == nil || !strings.Contains(err.Error(), "no changed file(s) matched --include") { + t.Fatalf("want nothing-included error naming --include; got %v", err) + } + if mockModel.reviewHit { + t.Error("model must not be invoked when no file is included") + } +} + +// TestReviewPipelineIncludeNoteReachesRunSummary: on the full TUI path +// the include note must ride the context-notes channel into the +// post-session run summary. +func TestReviewPipelineIncludeNoteReachesRunSummary(t *testing.T) { + stubProv := &stubProvider{ + supports: func(string) bool { return true }, + fetchInput: reviewInputWithTwoFileDiff(t), + } + mockModel := &stubModel{ + name: "codex", + reviewResult: &review.ModelReviewResult{ + Model: "codex", + Findings: []review.FindingCandidate{{ + File: "auth/session.go", Line: 13, Severity: "high", + Title: "t", Evidence: "e", SuggestedComment: "c", FixHint: "f", Confidence: 0.9, + }}, + }, + } + withFakePicker(t, map[string]model.Model{"codex": mockModel}) + withFakeTUI(t, func(*tui.Model) error { return nil }) + + root, out := newTestRootWithModels(stubProv, map[string]model.Model{"codex": mockModel}) + root.SetArgs([]string{"review", "https://github.com/owner/repo/pull/42", "--include", "*.go"}) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + if got := out.String(); !strings.Contains(got, "--include kept 1 of 2") { + t.Errorf("run summary missing inclusion note; got:\n%s", got) + } +} + +// TestReviewIncludeThenExcludeCompose: --include runs first, then +// --exclude carves exceptions out of the included set (gitignore-style +// precedence). --include '*' keeps both files; --exclude '*.json' then +// drops the lockfile, leaving only the source file in the prompt. +func TestReviewIncludeThenExcludeCompose(t *testing.T) { + stub := &stubProvider{ + supports: func(string) bool { return true }, + fetchInput: reviewInputWithTwoFileDiff(t), + } + root, out := newTestRoot(stub) + root.SetArgs([]string{ + "review", "https://github.com/owner/repo/pull/42", "--print-prompt", + "--include", "*", "--exclude", "*.json", + }) + + if err := root.Execute(); err != nil { + t.Fatalf("Execute: %v", err) + } + got := out.String() + if strings.Contains(got, "package-lock.json") { + t.Error("--exclude should carve the lockfile out of the included set") + } + if !strings.Contains(got, "auth/session.go") { + t.Error("included, non-excluded file missing from the printed prompt") + } +} + // fetcherStubProvider is a stubProvider that also implements // review.LinkedIssueFetcher, so command-level tests can drive the // context-enrichment paths through the real pipeline. diff --git a/internal/diff/include.go b/internal/diff/include.go new file mode 100644 index 0000000..e5a1378 --- /dev/null +++ b/internal/diff/include.go @@ -0,0 +1,61 @@ +package diff + +import ( + "fmt" + "path" + "strings" +) + +// Include is the allowlist inverse of Exclude: it keeps only the files +// matching at least one of the gitignore-lite patterns, dropping the rest +// from both the parsed file list and the raw unified diff. It returns the +// kept files, the rebuilt raw diff, and the dropped paths (in diff +// order). With no patterns it returns its inputs unchanged — an absent +// --include means "review everything", not "review nothing". +// +// Pattern rules are identical to Exclude (see matchPattern): +// - trailing "/" — directory tree, at any depth ("internal/", "auth/") +// - no "/" — basename glob via path.Match ("*.go") +// - otherwise — full-path glob via path.Match ("internal/app/*.go") +// +// A renamed file is kept when either its old or new path matches, the +// same OldPath/Path union Exclude uses — so a rename can't smuggle a file +// out of an allowlist any more than it can dodge a blocklist. +// +// Raw-segment correspondence is positional, exactly as in Exclude: a +// segment/file count mismatch is an error, never a guess. +func Include(files []*DiffFile, rawDiff string, patterns []string) (kept []*DiffFile, keptRaw string, dropped []string, err error) { + if len(patterns) == 0 { + return files, rawDiff, nil, nil + } + for _, pat := range patterns { + // Surface malformed globs immediately, naming --include — a + // typo'd pattern that silently matches nothing would filter the + // whole diff away under an allowlist. + if !strings.HasSuffix(pat, "/") { + if _, err := path.Match(pat, "probe"); err != nil { + return nil, "", nil, fmt.Errorf("invalid --include pattern %q: %w", pat, err) + } + } + } + + segments := splitRawSegments(rawDiff) + if len(segments) != len(files) { + return nil, "", nil, fmt.Errorf("raw diff has %d segment(s) but %d parsed file(s); refusing to filter against a mismatched diff", len(segments), len(files)) + } + + var rawKept strings.Builder + for i, f := range files { + keep, err := fileMatchesAny(f, patterns) + if err != nil { + return nil, "", nil, err + } + if !keep { + dropped = append(dropped, f.Path) + continue + } + kept = append(kept, f) + rawKept.WriteString(segments[i]) + } + return kept, rawKept.String(), dropped, nil +} diff --git a/internal/diff/include_test.go b/internal/diff/include_test.go new file mode 100644 index 0000000..84b35b0 --- /dev/null +++ b/internal/diff/include_test.go @@ -0,0 +1,155 @@ +package diff + +import ( + "strings" + "testing" +) + +// Include reuses the threeFileDiff fixture and parseThreeFileDiff helper +// defined in exclude_test.go — same package, same three targets (a +// root lockfile, a nested source file, a vendored file). + +func TestInclude_NoPatternsReturnsInputUnchanged(t *testing.T) { + files := parseThreeFileDiff(t) + kept, keptRaw, dropped, err := Include(files, threeFileDiff, nil) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 3 || keptRaw != threeFileDiff || len(dropped) != 0 { + t.Errorf("no patterns must be a no-op (review everything); kept=%d dropped=%d rawChanged=%v", + len(kept), len(dropped), keptRaw != threeFileDiff) + } +} + +// TestInclude_BasenameGlobKeepsOnlyMatches: --include is the inverse of +// --exclude — it keeps matching files and drops the rest. +func TestInclude_BasenameGlobKeepsOnlyMatches(t *testing.T) { + files := parseThreeFileDiff(t) + kept, keptRaw, dropped, err := Include(files, threeFileDiff, []string{"*.json"}) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 1 || kept[0].Path != "package-lock.json" { + t.Fatalf("want only package-lock.json kept, got %d files", len(kept)) + } + if len(dropped) != 2 { + t.Errorf("dropped = %v, want the two non-matching files", dropped) + } + if !strings.Contains(keptRaw, "package-lock.json") { + t.Error("kept raw missing the included file's segment") + } + for _, gone := range []string{"internal/auth/session.go", "vendor/lib/dep.go"} { + if strings.Contains(keptRaw, gone) { + t.Errorf("kept raw should not contain dropped file %q", gone) + } + } +} + +func TestInclude_TrailingSlashKeepsTreeAtAnyDepth(t *testing.T) { + files := parseThreeFileDiff(t) + kept, keptRaw, dropped, err := Include(files, threeFileDiff, []string{"auth/"}) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 1 || kept[0].Path != "internal/auth/session.go" { + t.Fatalf("auth/ should keep internal/auth/ at depth; kept=%d", len(kept)) + } + if len(dropped) != 2 { + t.Errorf("want 2 dropped, got %v", dropped) + } + if strings.Contains(keptRaw, "vendor/lib/dep.go") { + t.Error("kept raw should not contain a non-matching segment") + } +} + +func TestInclude_FullPathGlob(t *testing.T) { + files := parseThreeFileDiff(t) + kept, _, dropped, err := Include(files, threeFileDiff, []string{"internal/auth/*.go"}) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 1 || kept[0].Path != "internal/auth/session.go" || len(dropped) != 2 { + t.Errorf("full-path glob: kept=%d dropped=%v", len(kept), dropped) + } +} + +// TestInclude_MultiplePatternsUnion: a file is kept if it matches ANY +// pattern, so two patterns keep the union of their matches. +func TestInclude_MultiplePatternsUnion(t *testing.T) { + files := parseThreeFileDiff(t) + kept, _, dropped, err := Include(files, threeFileDiff, []string{"*.json", "vendor/"}) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 2 || len(dropped) != 1 || dropped[0] != "internal/auth/session.go" { + t.Errorf("union of *.json and vendor/ should keep 2, drop the source file; kept=%d dropped=%v", + len(kept), dropped) + } +} + +// TestInclude_RenameMatchesOldPath: a file renamed away from a matching +// path is still kept — symmetry with Exclude so a rename can't smuggle a +// file out of an allowlist any more than into a blocklist. +func TestInclude_RenameMatchesOldPath(t *testing.T) { + const renameDiff = `diff --git a/notes.lock b/notes.txt +similarity index 90% +rename from notes.lock +rename to notes.txt +index 1111111..2222222 100644 +--- a/notes.lock ++++ b/notes.txt +@@ -1,1 +1,1 @@ +-x ++y +` + files, err := Parse(renameDiff) + if err != nil { + t.Fatalf("Parse: %v", err) + } + kept, _, dropped, err := Include(files, renameDiff, []string{"*.lock"}) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 1 || len(dropped) != 0 { + t.Errorf("rename whose OldPath matches must be kept; kept=%d dropped=%v", len(kept), dropped) + } +} + +// TestInclude_NothingMatchesDropsAll: when no file matches, Include keeps +// nothing and reports every path as dropped. The app layer turns this +// into a clean pre-model error. +func TestInclude_NothingMatchesDropsAll(t *testing.T) { + files := parseThreeFileDiff(t) + kept, keptRaw, dropped, err := Include(files, threeFileDiff, []string{"*.rs"}) + if err != nil { + t.Fatalf("Include: %v", err) + } + if len(kept) != 0 || len(dropped) != 3 { + t.Errorf("kept=%d dropped=%d; want 0/3", len(kept), len(dropped)) + } + if strings.TrimSpace(keptRaw) != "" { + t.Errorf("kept raw should be empty, got %d bytes", len(keptRaw)) + } +} + +// TestInclude_InvalidPatternErrors: a malformed glob must error up front, +// naming the pattern and the --include flag, even if no file would have +// matched it. +func TestInclude_InvalidPatternErrors(t *testing.T) { + files := parseThreeFileDiff(t) + _, _, _, err := Include(files, threeFileDiff, []string{"[unclosed"}) + if err == nil || !strings.Contains(err.Error(), "[unclosed") || !strings.Contains(err.Error(), "--include") { + t.Errorf("invalid pattern should error naming the pattern and --include; got %v", err) + } +} + +// TestInclude_SegmentCountMismatchErrors: raw-segment to parsed-file +// correspondence is positional, exactly as in Exclude. +func TestInclude_SegmentCountMismatchErrors(t *testing.T) { + files := parseThreeFileDiff(t) + oneSegment := threeFileDiff[:strings.Index(threeFileDiff, "diff --git a/internal")] + _, _, _, err := Include(files, oneSegment, []string{"*.json"}) + if err == nil || !strings.Contains(err.Error(), "segment") { + t.Errorf("mismatched raw/files must error; got %v", err) + } +} diff --git a/internal/model/claudecli/adapter.go b/internal/model/claudecli/adapter.go index 89f13d0..85707d2 100644 --- a/internal/model/claudecli/adapter.go +++ b/internal/model/claudecli/adapter.go @@ -93,7 +93,7 @@ func (a *Adapter) Synthesize(ctx context.Context, input *review.ReviewInput, res // Synthesize (synthesis prompt). func (a *Adapter) executeWithPrompt(ctx context.Context, prompt string) (*review.ModelReviewResult, error) { if len(prompt) > a.inputBudget { - return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --exclude, or raise --input-budget", + return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --include/--exclude, or raise --input-budget", len(prompt), a.inputBudget, a.Name()) } diff --git a/internal/model/codexcli/adapter.go b/internal/model/codexcli/adapter.go index cce6327..208becc 100644 --- a/internal/model/codexcli/adapter.go +++ b/internal/model/codexcli/adapter.go @@ -100,7 +100,7 @@ func (a *Adapter) Synthesize(ctx context.Context, input *review.ReviewInput, res // Synthesize (synthesis prompt). func (a *Adapter) executeWithPrompt(ctx context.Context, prompt string) (*review.ModelReviewResult, error) { if len(prompt) > a.inputBudget { - return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --exclude, or raise --input-budget", + return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --include/--exclude, or raise --input-budget", len(prompt), a.inputBudget, a.Name()) } diff --git a/internal/model/geminicli/adapter.go b/internal/model/geminicli/adapter.go index 98010df..e462ab3 100644 --- a/internal/model/geminicli/adapter.go +++ b/internal/model/geminicli/adapter.go @@ -96,7 +96,7 @@ func (a *Adapter) Synthesize(ctx context.Context, input *review.ReviewInput, res // Synthesize (synthesis prompt). func (a *Adapter) executeWithPrompt(ctx context.Context, prompt string) (*review.ModelReviewResult, error) { if len(prompt) > a.inputBudget { - return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --exclude, or raise --input-budget", + return nil, fmt.Errorf("prompt size %d bytes exceeds input budget %d bytes for %s; review a smaller PR, filter files with --include/--exclude, or raise --input-budget", len(prompt), a.inputBudget, a.Name()) }