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
1 change: 1 addition & 0 deletions .claude/commands/open-code-review.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ ocr review --audience agent [user-args]
- If the user provides `--commit` or `--c`: pass through as-is.
- If the user provides `--from` and `--to`: pass through as-is.
- (Optional) Provide `--background "requirement context"` to review whether the requirements are correctly implemented.
- (Optional) Provide `--background-file ./requirements.md` to load the same context from a Markdown file (sanitised and limited to 8000 characters). Combined with `--background` the inline value is given first.
- Capture full stdout. Set a 5-minute timeout.
- If the `ocr` command is not found, install it by running `npm i -g @alibaba-group/open-code-review`.

Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -369,6 +369,7 @@ See the [`examples/`](./examples/) directory for integration examples:
| `--timeout` | — | `10` | Concurrent task timeout in minutes |
| `--audience` | — | `human` | `human` (show progress) or `agent` (summary only) |
| `--background` | `-b` | — | Optional requirement/business context for the review; auto-filled from commit message when using `--commit` |
| `--background-file` | `-B` | — | Optional requirement/business context from a Markdown file; Combined with `--background` the inline value is given first |
| `--model` | — | — | Select or override the LLM model for this review |
| `--rule` | — | — | Path to custom JSON review rules |
| `--max-tools` | — | built-in | Max tool call rounds per file; only takes effect when greater than template default |
Expand Down Expand Up @@ -430,6 +431,12 @@ ocr review --commit abc123 --model claude-sonnet-4-6
# Provide requirement context for more targeted review
ocr review --background "Adding rate limiting to the login API"

# Provide requirement context from a Markdown file
ocr review --background-file ./docs/my_business_context.md

# Combine inline context with a local context file (both are used)
ocr review --background "Focus on auth" --background-file ./docs/my_business_context.md

# Use custom review rules
ocr review --rule /path/to/my-rules.json

Expand Down
130 changes: 130 additions & 0 deletions cmd/opencodereview/background_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
package main

import (
"fmt"
"os"
"regexp"
"strings"
"unicode"
)

const (
backgroundSoftLimit = 2000
backgroundHardLimit = 8000
backgroundOpenTag = "<ocr_user_background>"
backgroundCloseTag = "</ocr_user_background>"
maxBackgroundFileBytes = 1 << 20 // 1 MB
)

var multiNewline = regexp.MustCompile(`\n{3,}`)

// mergeBackground combines the inline --background value (or an auto-populated
// commit message) with the content read from --background-file, separated by a
// blank line. The inline value is sanitised the same way as the file content so
// both portions are cleaned consistently. The file content is already wrapped
// and sanitised by loadBackgroundFile.
func mergeBackground(inline, fromFile string) string {
inline = sanitizeMarkdown(inline)
switch {
case inline == "":
return fromFile
case fromFile == "":
return inline
default:
return inline + "\n\n" + fromFile
}
}
Comment thread
Victor-D marked this conversation as resolved.

func loadBackgroundFile(path string) (string, error) {
info, err := os.Stat(path)
if err != nil {
return "", fmt.Errorf("read background file %q: %w", path, err)
}
if info.IsDir() {
return "", fmt.Errorf("background file %q is a directory, not a file", path)
}
if info.Size() > maxBackgroundFileBytes {
return "", fmt.Errorf(
"background file %q is %d bytes, exceeding the maximum of %d bytes; please provide a smaller file",
path, info.Size(), maxBackgroundFileBytes,
)
}

raw, err := os.ReadFile(path)
if err != nil {
return "", fmt.Errorf("read background file %q: %w", path, err)
}

cleaned := sanitizeMarkdown(string(raw))
if cleaned == "" {
return "", fmt.Errorf("background file %q is empty after sanitisation", path)
}

if strings.Contains(cleaned, backgroundOpenTag) || strings.Contains(cleaned, backgroundCloseTag) {
return "", fmt.Errorf(
"background file %q must not contain the reserved delimiters %q or %q",
path, backgroundOpenTag, backgroundCloseTag,
)
}

wrapped := backgroundOpenTag + "\n" + cleaned + "\n" + backgroundCloseTag
Comment thread
Victor-D marked this conversation as resolved.

if n := len([]rune(wrapped)); n > backgroundHardLimit {
return "", fmt.Errorf(
"background content is %d characters, exceeding the hard limit of %d (aborting)",
n, backgroundHardLimit,
)
} else if n > backgroundSoftLimit {
fmt.Fprintf(os.Stderr,
"[ocr] --background-file content is %d characters, exceeding the recommended %d (continuing but review quality might be impacted)\n",
n, backgroundSoftLimit,
)
}

return wrapped, nil
}

func sanitizeMarkdown(s string) string {
var b strings.Builder
b.Grow(len(s))

for _, r := range s {
switch r {
case '\n', '\t':
b.WriteRune(r)
continue
case '\r':
continue
}
if isForbiddenChar(r) {
continue
}
b.WriteRune(r)
}

collapsed := multiNewline.ReplaceAllString(b.String(), "\n\n")
return strings.TrimSpace(collapsed)
}

func isForbiddenChar(r rune) bool {
switch {
case r <= 0x1F: // C0 control characters (includes NUL)
return true
case r >= 0x7F && r <= 0x9F: // DEL and C1 control characters
return true
}

switch r {
case '\u200B', // zero-width space
'\u200C', // zero-width non-joiner
'\u200D', // zero-width joiner
'\u200E', // left-to-right mark
'\u200F', // right-to-left mark
'\u2060', // word joiner
'\u00AD', // soft hyphen
'\uFEFF': // BOM / zero-width no-break space
return true
}

return unicode.Is(unicode.Cf, r)
}
Loading