Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/opencodereview/delegate_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ type delegateContext struct {
}

func loadDelegateContext(opts delegateOptions) (*delegateContext, error) {
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, 0, opts.maxGitProcs, true)
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, reviewContentRef(opts.from, opts.to, opts.commit), 0, opts.maxGitProcs, true)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/opencodereview/review_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@ func init() {
}

func executeReview(opts reviewOptions) error {
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, opts.maxTools, opts.maxGitProcs, true)
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, reviewContentRef(opts.from, opts.to, opts.commit), opts.maxTools, opts.maxGitProcs, true)
if err != nil {
return err
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/opencodereview/rules_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func runRulesCheck(filePath string) error {
return err
}

resolver, _, err := rules.NewResolver(resolvedRepo, rulesCheckRulePath)
resolver, _, err := rules.NewResolver(resolvedRepo, rulesCheckRulePath, rules.ResolverOptions{})
if err != nil {
return fmt.Errorf("load rules: %w", err)
}
Expand All @@ -58,7 +58,7 @@ func runRulesCheck(filePath string) error {
return fmt.Errorf("resolver does not support detail inspection")
}

detail := dr.ResolveDetail(strings.ToLower(filePath))
detail := dr.ResolveDetail(filePath)

sourceLabel := map[string]string{
"custom": "Custom (--rule)",
Expand Down
135 changes: 135 additions & 0 deletions cmd/opencodereview/rules_cmd_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 alibaba/open-code-review Contributors

package main

import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)

func initRulesCheckTestRepo(t *testing.T) string {
t.Helper()
repo := t.TempDir()
git := func(args ...string) {
t.Helper()
cmd := exec.Command("git", args...)
cmd.Dir = repo
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git %v: %v\n%s", args, err, out)
}
}
git("init")
git("config", "user.email", "t@t.co")
git("config", "user.name", "t")
return repo
}

func writeRulesCheckTestFile(t *testing.T, repo, relPath, content string) {
t.Helper()
full := filepath.Join(repo, relPath)
if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

// setRulesCheckRepo points the rulesCheckCmd's package-level --repo flag at
// repo for the duration of the test, restoring it afterward. runRulesCheck
// reads rulesCheckRepoDir directly (it's a singleton cobra command's bound
// flag var, not a per-call parameter), so tests must set it this way.
func setRulesCheckRepo(t *testing.T, repo string) {
t.Helper()
orig := rulesCheckRepoDir
rulesCheckRepoDir = repo
t.Cleanup(func() { rulesCheckRepoDir = orig })
}

// TestRunRulesCheck_ObjCSniffOverridesMatlab exercises peekFirstLine's actual
// disk-read path: system_rules.json maps "**/*.m" to matlab.md, but an
// Objective-C file (recognizable by its #import/@implementation header)
// should sniff away from that pattern and use the dedicated objc.md rule
// (rather than the incorrect MATLAB rule) instead.
func TestRunRulesCheck_ObjCSniffOverridesMatlab(t *testing.T) {
repo := initRulesCheckTestRepo(t)
writeRulesCheckTestFile(t, repo, "ios/ViewController.m",
"#import \"ViewController.h\"\n\n@implementation ViewController\n@end\n")
setRulesCheckRepo(t, repo)

got := captureStdout(t, func() {
if err := runRulesCheck("ios/ViewController.m"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if !strings.Contains(got, "Pattern: **/*.m (sniffed: objc)") {
t.Errorf("expected the sniffed-objc pattern label, got:\n%s", got)
}
if strings.Contains(got, "MATLAB") {
t.Errorf("expected MATLAB-specific guidance to be replaced by the objc rule, got:\n%s", got)
}
}

// TestRunRulesCheck_MatlabFileStaysMatlab is the control case: a genuine
// MATLAB file (function header, no ObjC signals) must still resolve via the
// plain "**/*.m" pattern.
func TestRunRulesCheck_MatlabFileStaysMatlab(t *testing.T) {
repo := initRulesCheckTestRepo(t)
writeRulesCheckTestFile(t, repo, "Models/main.m",
"function y = main(x)\n y = x + 1;\nend\n")
setRulesCheckRepo(t, repo)

got := captureStdout(t, func() {
if err := runRulesCheck("Models/main.m"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if !strings.Contains(got, "Pattern: **/*.m") {
t.Errorf("expected the matlab pattern to still match, got:\n%s", got)
}
}

// TestRunRulesCheck_MissingFileFallsBackToPathOnlyMatch covers peekFirstLine's
// error path: a file path that doesn't exist on disk (e.g. checking a rule
// before creating the file) must not error out — content sniffing is simply
// skipped and resolution falls back to plain path matching.
func TestRunRulesCheck_MissingFileFallsBackToPathOnlyMatch(t *testing.T) {
repo := initRulesCheckTestRepo(t)
setRulesCheckRepo(t, repo)

got := captureStdout(t, func() {
if err := runRulesCheck("Models/does_not_exist.m"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if !strings.Contains(got, "Pattern: **/*.m") {
t.Errorf("expected the matlab pattern to match by path alone, got:\n%s", got)
}
}

// TestRunRulesCheck_BlankOnlyFileFallsBackToPathOnlyMatch covers
// peekFirstLine's other empty-result path: the file exists but has no
// non-blank line to sniff (e.g. only whitespace so far), so it must behave
// like no content was available rather than erroring.
func TestRunRulesCheck_BlankOnlyFileFallsBackToPathOnlyMatch(t *testing.T) {
repo := initRulesCheckTestRepo(t)
writeRulesCheckTestFile(t, repo, "Models/blank.m", "\n \n\t\n")
setRulesCheckRepo(t, repo)

got := captureStdout(t, func() {
if err := runRulesCheck("Models/blank.m"); err != nil {
t.Fatalf("unexpected error: %v", err)
}
})

if !strings.Contains(got, "Pattern: **/*.m") {
t.Errorf("expected the matlab pattern to match by path alone, got:\n%s", got)
}
}
2 changes: 1 addition & 1 deletion cmd/opencodereview/scan_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ func splitPaths(raw string) []string {
}

func executeScan(opts scanOptions) error {
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, opts.maxTools, opts.maxGitProcs, false)
cc, err := loadCommonContext(opts.repoDir, opts.rulePath, "", opts.maxTools, opts.maxGitProcs, false)
if err != nil {
return err
}
Expand Down
32 changes: 29 additions & 3 deletions cmd/opencodereview/shared.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,11 @@ type commonContext struct {
// requireGit=true fails fast when the directory is not a git repo (review
// path: diff concept requires git). requireGit=false allows non-git
// directories (scan path: provider falls back to filepath.Walk).
func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int, requireGit bool) (*commonContext, error) {
//
// contentRef is the git ref whose file content the rule resolver should
// inspect when disambiguating ambiguous extensions (see reviewContentRef).
// Pass "" to read the working tree, which is what scan wants.
func loadCommonContext(repoDirInput, rulePath, contentRef string, maxTools, maxGitProcs int, requireGit bool) (*commonContext, error) {
tpl, err := template.LoadDefault()
if err != nil {
return nil, fmt.Errorf("load default template: %w", err)
Expand All @@ -68,7 +72,14 @@ func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int,
return nil, err
}

resolver, fileFilter, err := rules.NewResolver(repoDir, rulePath)
// Built before the resolver: the sniffer reads file content at contentRef
// through this limiter.
gitRunner := gitcmd.New(maxGitProcs)

resolver, fileFilter, err := rules.NewResolver(repoDir, rulePath, rules.ResolverOptions{
Ref: contentRef,
Runner: gitRunner,
})
if err != nil {
return nil, fmt.Errorf("load rules: %w", err)
}
Expand All @@ -78,11 +89,26 @@ func loadCommonContext(repoDirInput, rulePath string, maxTools, maxGitProcs int,
RepoDir: repoDir,
Resolver: resolver,
FileFilter: fileFilter,
GitRunner: gitcmd.New(maxGitProcs),
GitRunner: gitRunner,
IsGitRepo: isGit,
}, nil
}

// reviewContentRef returns the ref whose content the rule resolver should read,
// mirroring how diff.Provider picks the ref it passes to finalizeDiff: the head
// of the range in range mode, the commit in commit mode, and "" for workspace
// mode (where the working tree is the thing under review).
func reviewContentRef(from, to, commit string) string {
switch {
case commit != "":
return commit
case from != "" && to != "":
return to
default:
return ""
}
}

// resolveWorkingDir returns (absPath, isGitRepo, err). When requireGit is
// true, returns an error if the directory is not a git repo. When false,
// returns IsGitRepo=false instead of erroring (scan path uses this).
Expand Down
25 changes: 25 additions & 0 deletions cmd/opencodereview/shared_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -221,3 +221,28 @@ func TestResolveWorkingDir_GitRepo(t *testing.T) {
}
_ = isGit
}

// reviewContentRef decides which ref the rule resolver reads file content at
// when disambiguating ambiguous extensions. It must mirror how diff.Provider
// picks the ref it hands to finalizeDiff, or the sniff would inspect content
// from a different commit than the one under review.
func TestReviewContentRef(t *testing.T) {
tests := []struct {
name, from, to, commit, want string
}{
{name: "commit mode wins", commit: "abc123", want: "abc123"},
{name: "commit wins over a range", from: "main", to: "feat", commit: "abc123", want: "abc123"},
{name: "range mode uses the head", from: "main", to: "feat", want: "feat"},
{name: "workspace mode has no ref", want: ""},
{name: "half a range is not a range", from: "main", want: ""},
{name: "to without from is not a range", to: "feat", want: ""},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := reviewContentRef(tt.from, tt.to, tt.commit); got != tt.want {
t.Errorf("reviewContentRef(%q, %q, %q) = %q, want %q",
tt.from, tt.to, tt.commit, got, tt.want)
}
})
}
}
2 changes: 1 addition & 1 deletion internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -1108,7 +1108,7 @@ func (a *Agent) executeSubtask(ctx context.Context, d model.Diff) (bool, *subtas
// Build change-files list excluding current file
changeFilesExcludingCurrent := a.buildChangeFilesExcept(newPath)

rule := a.resolveSystemRule(strings.ToLower(newPath))
rule := a.resolveSystemRule(newPath)

threshold := a.args.Template.PlanModeLineThreshold
changeLines := d.Insertions + d.Deletions
Expand Down
1 change: 1 addition & 0 deletions internal/config/allowlist/allowed_ext_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func TestIsAllowedExt(t *testing.T) {
{".JL", true},
{".hcl", true},
{".HCL", true},
{".m", true},
{".tfvars", true},
{".TFVARS", true},
{".bicep", true},
Expand Down
4 changes: 2 additions & 2 deletions internal/config/rules/canonical_config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ func TestComposedResolverCanonicalConfig(t *testing.T) {
t.Fatalf("write rule.json: %v", err)
}

resolver, _, err := NewResolver(dir, "")
resolver, _, err := NewResolver(dir, "", ResolverOptions{})
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
Expand Down Expand Up @@ -88,7 +88,7 @@ func TestComposedResolverCanonicalConfig_ProjectRuleChangeChangesOutput(t *testi
if err := os.WriteFile(filepath.Join(ocrDir, "rule.json"), []byte(ruleJSON), 0o644); err != nil {
t.Fatalf("write rule.json: %v", err)
}
resolver, _, err := NewResolver(dir, "")
resolver, _, err := NewResolver(dir, "", ResolverOptions{})
if err != nil {
t.Fatalf("NewResolver: %v", err)
}
Expand Down
Loading