mirror of
https://github.com/alibaba/open-code-review.git
synced 2026-07-09 17:28:58 +00:00
feat(rules): add ocr rules check command to preview effective rule for a file path
This commit is contained in:
parent
971ab0a626
commit
834a4e7e3f
6 changed files with 285 additions and 1 deletions
|
|
@ -103,6 +103,7 @@ ocr review --commit abc123
|
|||
| Command | Alias | Description |
|
||||
|---------|-------|-------------|
|
||||
| `ocr review` | `ocr r` | Start a code review |
|
||||
| `ocr rules check <file>` | — | Preview which review rule applies to a file path |
|
||||
| `ocr config set <key> <value>` | — | 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
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ ocr review --commit abc123
|
|||
| 命令 | 别名 | 描述 |
|
||||
|------|------|------|
|
||||
| `ocr review` | `ocr r` | 开始代码审查 |
|
||||
| `ocr rules check <file>` | — | 预览某个文件路径生效的审查规则 |
|
||||
| `ocr config set <key> <value>` | — | 设置配置项 |
|
||||
| `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
|
||||
|
|
|
|||
|
|
@ -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.`)
|
||||
}
|
||||
|
|
|
|||
104
cmd/opencodereview/rules_cmd.go
Normal file
104
cmd/opencodereview/rules_cmd.go
Normal file
|
|
@ -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-command>
|
||||
|
||||
Sub-commands:
|
||||
check <file> 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] <file-path>
|
||||
|
||||
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`)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue