From 834a4e7e3f76b218cb8cb89d3b40b61789c01d57 Mon Sep 17 00:00:00 2001 From: kite Date: Tue, 26 May 2026 22:11:39 +0800 Subject: [PATCH] feat(rules): add `ocr rules check` command to preview effective rule for a file path --- README.md | 5 + README.zh-CN.md | 5 + cmd/opencodereview/main.go | 6 +- cmd/opencodereview/rules_cmd.go | 104 +++++++++++++++++++ internal/config/rules/system_rules.go | 56 +++++++++++ internal/config/rules/system_rules_test.go | 110 +++++++++++++++++++++ 6 files changed, 285 insertions(+), 1 deletion(-) create mode 100644 cmd/opencodereview/rules_cmd.go diff --git a/README.md b/README.md index fa5c932..c9ebca4 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ ocr review --commit abc123 | Command | Alias | Description | |---------|-------|-------------| | `ocr review` | `ocr r` | Start a code review | +| `ocr rules check ` | — | Preview which review rule applies to a file path | | `ocr config set ` | — | Set configuration values | | `ocr llm test` | — | Test LLM connectivity | | `ocr viewer` | `ocr v` | Launch WebUI session viewer on `localhost:5483` | @@ -138,6 +139,10 @@ ocr review --commit abc123 --format json --audience agent # Use custom review rules ocr review --rule /path/to/my-rules.json +# Preview which rule applies to a file +ocr rules check src/main/java/com/example/Foo.java +ocr rules check --rule custom.json src/main/resources/mapper/UserMapper.xml + # View review session history in browser ocr viewer ocr viewer --addr :3000 diff --git a/README.zh-CN.md b/README.zh-CN.md index cceaa69..b33798c 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -103,6 +103,7 @@ ocr review --commit abc123 | 命令 | 别名 | 描述 | |------|------|------| | `ocr review` | `ocr r` | 开始代码审查 | +| `ocr rules check ` | — | 预览某个文件路径生效的审查规则 | | `ocr config set ` | — | 设置配置项 | | `ocr llm test` | — | 测试 LLM 连通性 | | `ocr viewer` | `ocr v` | 启动 WebUI 会话查看器,地址 `localhost:5483` | @@ -138,6 +139,10 @@ ocr review --commit abc123 --format json --audience agent # 使用自定义审查规则 ocr review --rule /path/to/my-rules.json +# 预览某个文件路径生效的规则 +ocr rules check src/main/java/com/example/Foo.java +ocr rules check --rule custom.json src/main/resources/mapper/UserMapper.xml + # 在浏览器中查看审查会话历史 ocr viewer ocr viewer --addr :3000 diff --git a/cmd/opencodereview/main.go b/cmd/opencodereview/main.go index f6f9ce2..f5f1ce6 100644 --- a/cmd/opencodereview/main.go +++ b/cmd/opencodereview/main.go @@ -50,6 +50,8 @@ func dispatch() error { return runConfig(args[1:]) case "llm": return runLLM(args[1:]) + case "rules": + return runRules(args[1:]) case "viewer": return runViewer(args[1:]) case "-h", "--help": @@ -68,9 +70,10 @@ Usage: Commands: review, r Start a code review + rules Inspect and debug review rules config Manage configuration settings llm LLM utility commands - viewer Start the WebUI session viewer + viewer Start the WebUI session viewer version Show version information Examples: @@ -81,6 +84,7 @@ Examples: ocr version Show version info Use "ocr review -h" for more information about review. +Use "ocr rules -h" for more information about rules. Use "ocr config" for more information about config. Use "ocr llm" for more information about LLM utilities.`) } diff --git a/cmd/opencodereview/rules_cmd.go b/cmd/opencodereview/rules_cmd.go new file mode 100644 index 0000000..cfb9b5b --- /dev/null +++ b/cmd/opencodereview/rules_cmd.go @@ -0,0 +1,104 @@ +package main + +import ( + "fmt" + "strings" + + "github.com/open-code-review/open-code-review/internal/config/rules" +) + +func runRules(args []string) error { + if len(args) == 0 { + printRulesUsage() + return nil + } + switch args[0] { + case "check": + return runRulesCheck(args[1:]) + case "-h", "--help": + printRulesUsage() + return nil + default: + return fmt.Errorf("unknown rules sub-command: %s\nRun 'ocr rules -h' for usage", args[0]) + } +} + +func runRulesCheck(args []string) error { + a := newOcrFlagSet("ocr rules check") + var repoDir, rulePath string + a.StringVar(&repoDir, "repo", "", "root directory of the git repository (default: current dir)") + a.StringVar(&rulePath, "rule", "", "path to JSON file with custom review rules") + if err := a.Parse(args); err != nil { + return err + } + if a.showHelp { + printRulesCheckUsage() + return nil + } + + rest := a.fs.Args() + if len(rest) == 0 { + printRulesCheckUsage() + return nil + } + filePath := rest[0] + + resolvedRepo, err := resolveRepoDir(repoDir) + if err != nil { + return err + } + + resolver, _, err := rules.NewResolver(resolvedRepo, rulePath) + if err != nil { + return fmt.Errorf("load rules: %w", err) + } + + dr, ok := resolver.(rules.DetailResolver) + if !ok { + return fmt.Errorf("resolver does not support detail inspection") + } + + detail := dr.ResolveDetail(strings.ToLower(filePath)) + + sourceLabel := map[string]string{ + "custom": "Custom (--rule)", + "project": "Project (.opencodereview/rule.json)", + "global": "Global (~/.opencodereview/rule.json)", + "system": "System built-in", + } + + fmt.Printf("File: %s\n", filePath) + fmt.Printf("Source: %s\n", sourceLabel[detail.Source]) + fmt.Printf("Pattern: %s\n", detail.Pattern) + fmt.Println("Rule:") + fmt.Println(strings.Repeat("─", 40)) + fmt.Println(detail.Rule) + fmt.Println(strings.Repeat("─", 40)) + + return nil +} + +func printRulesUsage() { + fmt.Println(`Usage: + ocr rules + +Sub-commands: + check Show which review rule applies to a given file path + +Use "ocr rules check -h" for more information.`) +} + +func printRulesCheckUsage() { + fmt.Println(`Usage: + ocr rules check [flags] + +Show which review rule applies to the given file path, including its source layer and matched pattern. + +Flags: + --repo Root directory of the git repository (default: current dir) + --rule Path to a custom rule JSON file + +Examples: + ocr rules check src/main/java/com/example/Foo.java + ocr rules check --rule custom.json src/main/resources/mapper/UserMapper.xml`) +} diff --git a/internal/config/rules/system_rules.go b/internal/config/rules/system_rules.go index 9bc17b4..d3740ff 100644 --- a/internal/config/rules/system_rules.go +++ b/internal/config/rules/system_rules.go @@ -108,6 +108,18 @@ func LoadDefault() (*SystemRule, error) { return &rule, nil } +// RuleDetail contains the resolved rule along with metadata about its source. +type RuleDetail struct { + Rule string // rule text + Source string // "custom" | "project" | "global" | "system" + Pattern string // glob pattern that matched, or "default" for fallback +} + +// DetailResolver extends Resolver with source metadata. +type DetailResolver interface { + ResolveDetail(path string) RuleDetail +} + // Resolve returns the rule text for a given file path. // Patterns with brace expansion like "*.{go,py}" are expanded into "*.go", "*.py". // The first match wins; if none match, it falls back to DefaultRule. @@ -124,6 +136,18 @@ func (r *SystemRule) Resolve(path string) string { return r.DefaultRule } +func (r *SystemRule) resolveDetail(path string) RuleDetail { + for _, pr := range r.PathRules { + expanded := expandBraces(pr.Pattern) + for _, p := range expanded { + if matched, _ := doublestar.Match(p, path); matched { + return RuleDetail{Rule: pr.Rule, Source: "system", Pattern: pr.Pattern} + } + } + } + return RuleDetail{Rule: r.DefaultRule, Source: "system", Pattern: "default"} +} + // expandBraces turns "{a,b,c}" style patterns into individual strings. // e.g. "*.go.{java,kotlin}" → ["*.go.java", "*.go.kotlin"]. // If no braces exist, returns the original pattern unchanged. @@ -340,6 +364,20 @@ func (c *composedResolver) Resolve(path string) string { return c.system.Resolve(path) } +// ResolveDetail returns the matched rule along with its source layer and pattern. +func (c *composedResolver) ResolveDetail(path string) RuleDetail { + if detail := matchProjectRuleDetail(c.custom, path, "custom"); detail != nil { + return *detail + } + if detail := matchProjectRuleDetail(c.project, path, "project"); detail != nil { + return *detail + } + if detail := matchProjectRuleDetail(c.global, path, "global"); detail != nil { + return *detail + } + return c.system.resolveDetail(path) +} + func matchProjectRule(pr *ProjectRule, path string) string { if pr == nil { return "" @@ -354,3 +392,21 @@ func matchProjectRule(pr *ProjectRule, path string) string { } return "" } + +func matchProjectRuleDetail(pr *ProjectRule, path, source string) *RuleDetail { + if pr == nil { + return nil + } + for _, entry := range pr.Rules { + if entry.Rule == "" { + continue + } + expanded := expandBraces(entry.Path) + for _, p := range expanded { + if matched, _ := doublestar.Match(p, path); matched { + return &RuleDetail{Rule: entry.Rule, Source: source, Pattern: entry.Path} + } + } + } + return nil +} diff --git a/internal/config/rules/system_rules_test.go b/internal/config/rules/system_rules_test.go index 06d4147..da23e58 100644 --- a/internal/config/rules/system_rules_test.go +++ b/internal/config/rules/system_rules_test.go @@ -521,6 +521,116 @@ func TestNewResolver_FileFilterFallsToProject(t *testing.T) { } } +func TestResolveDetail_SystemDefault(t *testing.T) { + resolver, _, err := NewResolver(t.TempDir(), "") + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + dr := resolver.(DetailResolver) + + detail := dr.ResolveDetail("readme.md") + if detail.Source != "system" { + t.Errorf("expected source 'system', got %q", detail.Source) + } + if detail.Pattern != "default" { + t.Errorf("expected pattern 'default', got %q", detail.Pattern) + } + if !strings.Contains(detail.Rule, "Correctness") { + t.Errorf("expected default rule content, got %q", truncate(detail.Rule, 80)) + } +} + +func TestResolveDetail_SystemPatternMatch(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + resolver, _, err := NewResolver(t.TempDir(), "") + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + dr := resolver.(DetailResolver) + + detail := dr.ResolveDetail("src/main/foo.java") + if detail.Source != "system" { + t.Errorf("expected source 'system', got %q", detail.Source) + } + if detail.Pattern != "**/*.java" { + t.Errorf("expected pattern '**/*.java', got %q", detail.Pattern) + } + if !strings.Contains(detail.Rule, "逻辑错误识别") { + t.Errorf("expected java rule, got %q", truncate(detail.Rule, 80)) + } +} + +func TestResolveDetail_ProjectOverridesSystem(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + dir := t.TempDir() + ocrDir := filepath.Join(dir, ".opencodereview") + if err := os.MkdirAll(ocrDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + ruleJSON := `{"rules":[{"path":"src/**/*.java","rule":"project-java-rule"}]}` + if err := os.WriteFile(filepath.Join(ocrDir, "rule.json"), []byte(ruleJSON), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + resolver, _, err := NewResolver(dir, "") + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + dr := resolver.(DetailResolver) + + detail := dr.ResolveDetail("src/main/foo.java") + if detail.Source != "project" { + t.Errorf("expected source 'project', got %q", detail.Source) + } + if detail.Pattern != "src/**/*.java" { + t.Errorf("expected pattern 'src/**/*.java', got %q", detail.Pattern) + } + if detail.Rule != "project-java-rule" { + t.Errorf("expected 'project-java-rule', got %q", detail.Rule) + } + + // Unmatched path falls to system + detail = dr.ResolveDetail("other/bar.java") + if detail.Source != "system" { + t.Errorf("expected source 'system', got %q", detail.Source) + } +} + +func TestResolveDetail_CustomOverridesAll(t *testing.T) { + // Project rule + repoDir := t.TempDir() + ocrDir := filepath.Join(repoDir, ".opencodereview") + if err := os.MkdirAll(ocrDir, 0o755); err != nil { + t.Fatalf("mkdir: %v", err) + } + projJSON := `{"rules":[{"path":"**/*.java","rule":"project-java-rule"}]}` + if err := os.WriteFile(filepath.Join(ocrDir, "rule.json"), []byte(projJSON), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + // Custom rule (highest priority) + customDir := t.TempDir() + customJSON := `{"rules":[{"path":"**/*.java","rule":"custom-java-rule"}]}` + customPath := filepath.Join(customDir, "custom.json") + if err := os.WriteFile(customPath, []byte(customJSON), 0o644); err != nil { + t.Fatalf("write: %v", err) + } + + resolver, _, err := NewResolver(repoDir, customPath) + if err != nil { + t.Fatalf("NewResolver: %v", err) + } + dr := resolver.(DetailResolver) + + detail := dr.ResolveDetail("src/foo.java") + if detail.Source != "custom" { + t.Errorf("expected source 'custom', got %q", detail.Source) + } + if detail.Rule != "custom-java-rule" { + t.Errorf("expected 'custom-java-rule', got %q", detail.Rule) + } +} + func TestNewResolver_BraceExpansionInProjectRule(t *testing.T) { dir := t.TempDir() ocrDir := filepath.Join(dir, ".opencodereview")