feat(rules): add ocr rules check command to preview effective rule for a file path

This commit is contained in:
kite 2026-05-26 22:11:39 +08:00
parent 971ab0a626
commit 834a4e7e3f
6 changed files with 285 additions and 1 deletions

View file

@ -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

View file

@ -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

View file

@ -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.`)
}

View 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`)
}

View file

@ -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
}

View file

@ -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")