Skip to content

Commit bc8fd98

Browse files
feat: add --tools flag to run only static analysis and display tool findings in the CLI
LiveReview Pre-Commit Check: skipped
1 parent 301e8d1 commit bc8fd98

8 files changed

Lines changed: 115 additions & 18 deletions

File tree

internal/appcore/auth_recovery.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,8 +92,8 @@ func isLiveReviewAPIKeyInvalid(err error) bool {
9292
return code == liveReviewAPIKeyInvalidCode
9393
}
9494

95-
func submitReviewWithRecovery(config Config, base64Diff, repoName string, verbose bool) (reviewmodel.DiffReviewCreateResponse, Config, error) {
96-
submitResp, err := reviewapi.SubmitReview(config.APIURL, config.APIKey, base64Diff, repoName, verbose)
95+
func submitReviewWithRecovery(config Config, base64Diff, repoName string, toolsOnly bool, verbose bool) (reviewmodel.DiffReviewCreateResponse, Config, error) {
96+
submitResp, err := reviewapi.SubmitReview(config.APIURL, config.APIKey, base64Diff, repoName, toolsOnly, verbose)
9797
if err == nil {
9898
return submitResp, config, nil
9999
}
@@ -107,7 +107,7 @@ func submitReviewWithRecovery(config Config, base64Diff, repoName string, verbos
107107
}
108108

109109
fmt.Println("Retrying review submission with refreshed credentials...")
110-
retryResp, retryErr := reviewapi.SubmitReview(recoveredConfig.APIURL, recoveredConfig.APIKey, base64Diff, repoName, verbose)
110+
retryResp, retryErr := reviewapi.SubmitReview(recoveredConfig.APIURL, recoveredConfig.APIKey, base64Diff, repoName, toolsOnly, verbose)
111111
if retryErr != nil {
112112
return reviewmodel.DiffReviewCreateResponse{}, recoveredConfig, retryErr
113113
}

internal/appcore/review_runtime.go

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ func runReviewWithOptions(opts reviewopts.Options) error {
394394
submitResp = buildFakeSubmitResponse()
395395
} else {
396396
var updatedConfig Config
397-
submitResp, updatedConfig, err = submitReviewWithRecovery(*config, base64Diff, repoName, verbose)
397+
submitResp, updatedConfig, err = submitReviewWithRecovery(*config, base64Diff, repoName, opts.ToolsOnly, verbose)
398398
config = &updatedConfig
399399
}
400400
if err != nil {
@@ -797,35 +797,43 @@ func runReviewWithOptions(opts reviewopts.Options) error {
797797
}))
798798
// Proxy endpoint for review-events API to avoid CORS
799799
mux.HandleFunc("/api/v1/diff-review/", requireSession(reviewID, func(w http.ResponseWriter, r *http.Request) {
800-
if fakeMode {
801-
if r.Method != http.MethodGet {
802-
w.WriteHeader(http.StatusMethodNotAllowed)
803-
return
804-
}
805-
if !strings.HasSuffix(r.URL.Path, "/events") {
806-
http.NotFound(w, r)
807-
return
808-
}
800+
if r.Method != http.MethodGet {
801+
w.WriteHeader(http.StatusMethodNotAllowed)
802+
return
803+
}
809804

805+
// Both fake and real modes handle the /events sub-path locally.
806+
// The backend has no /api/v1/diff-review/:id/events route — only
807+
// /api/v1/reviews/:id/events exists. Proxying would always return
808+
// 404, causing fetchFinalReviewData in the JS to never be called.
809+
// Instead, respond with meta.status from ReviewState so the JS
810+
// detects "completed" and calls fetchFinalReviewData itself (which
811+
// proxies to GET /api/v1/diff-review/:id — a route that does exist).
812+
if strings.HasSuffix(r.URL.Path, "/events") {
810813
reviewStateMu.RLock()
811814
state := currentReviewState
812815
reviewStateMu.RUnlock()
813816
if state == nil {
814817
http.Error(w, "No review in progress", http.StatusNotFound)
815818
return
816819
}
817-
818820
w.Header().Set("Content-Type", "application/json")
819821
if err := json.NewEncoder(w).Encode(buildFakeEventsResponse(state.Snapshot())); err != nil {
820822
if verbose {
821-
log.Printf("failed to write fake events response: %v", err)
823+
log.Printf("failed to write events response: %v", err)
822824
}
823825
}
824826
return
825827
}
826828

829+
if fakeMode {
830+
http.NotFound(w, r)
831+
return
832+
}
833+
827834
handleReviewEventsProxy(w, r, *config, reviewID, verbose)
828835
}))
836+
829837
// Proxy feedback endpoints — adds API key so browser never holds the key
830838
mux.HandleFunc("/api/v1/feedback", requireSession(reviewID, func(w http.ResponseWriter, r *http.Request) {
831839
handleFeedbackProxy(w, r, *config, verbose, reviewID)
@@ -1842,6 +1850,32 @@ func renderPretty(result *reviewmodel.DiffReviewResponse) error {
18421850
}
18431851
}
18441852

1853+
if len(result.ToolResults) > 0 {
1854+
hasFindings := false
1855+
for _, tr := range result.ToolResults {
1856+
if len(tr.Findings) > 0 {
1857+
hasFindings = true
1858+
break
1859+
}
1860+
}
1861+
if hasFindings {
1862+
fmt.Println("\nStatic Analysis Tool Results:")
1863+
for _, tr := range result.ToolResults {
1864+
for _, f := range tr.Findings {
1865+
colStr := ""
1866+
if f.Col > 0 {
1867+
colStr = fmt.Sprintf(":%d", f.Col)
1868+
}
1869+
ruleStr := ""
1870+
if f.Rule != "" {
1871+
ruleStr = fmt.Sprintf(" %s", f.Rule)
1872+
}
1873+
fmt.Printf("[%s] %s:%d%s%s %s\n", tr.ToolName, f.File, f.Line, colStr, ruleStr, f.Message)
1874+
}
1875+
}
1876+
}
1877+
}
1878+
18451879
fmt.Println("\n" + strings.Repeat("=", 80))
18461880
fmt.Printf("Review complete: %d total comment(s)\n", countTotalComments(result.Files))
18471881
fmt.Println(strings.Repeat("=", 80) + "\n")

internal/appcore/review_state.go

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ func (rs *ReviewState) UpdateFromResult(result *reviewmodel.DiffReviewResponse)
8888
rs.Status = result.Status
8989
rs.Summary = result.Summary
9090

91-
// Merge comments from result into existing files (preserving hunks)
91+
// Merge AI comments from result into existing files (preserving hunks)
9292
totalComments := 0
9393
for i := range rs.Files {
9494
for _, resultFile := range result.Files {
@@ -99,7 +99,37 @@ func (rs *ReviewState) UpdateFromResult(result *reviewmodel.DiffReviewResponse)
9999
}
100100
totalComments += len(rs.Files[i].Comments)
101101
}
102+
103+
// Inject tool_comments into the files list. Tool findings may reference
104+
// files not in the diff (or lines outside hunks) so we handle them
105+
// separately from the diff-matched AI comments above.
106+
fileIndex := make(map[string]int, len(rs.Files))
107+
for i, f := range rs.Files {
108+
fileIndex[f.FilePath] = i
109+
}
110+
for _, tc := range result.ToolComments {
111+
comment := reviewmodel.DiffReviewComment{
112+
Line: tc.Line,
113+
Content: tc.Content,
114+
Severity: tc.Severity,
115+
Category: tc.Category,
116+
}
117+
if idx, ok := fileIndex[tc.FilePath]; ok {
118+
rs.Files[idx].Comments = append(rs.Files[idx].Comments, comment)
119+
} else {
120+
// File not in diff — create a stub entry so the comment is visible
121+
rs.Files = append(rs.Files, reviewmodel.DiffReviewFileResult{
122+
FilePath: tc.FilePath,
123+
Comments: []reviewmodel.DiffReviewComment{comment},
124+
})
125+
fileIndex[tc.FilePath] = len(rs.Files) - 1
126+
}
127+
totalComments++
128+
}
129+
130+
102131
rs.TotalComments = totalComments
132+
rs.TotalFiles = len(rs.Files)
103133
}
104134

105135
// SetCompleted marks the review as completed

internal/reviewapi/helpers.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,11 @@ func formatJSONParseError(body []byte, contentType string, parseErr error) error
144144
parseErr, contentType, preview)
145145
}
146146

147-
func SubmitReview(apiURL, apiKey, base64Diff, repoName string, verbose bool) (reviewmodel.DiffReviewCreateResponse, error) {
147+
func SubmitReview(apiURL, apiKey, base64Diff, repoName string, toolsOnly bool, verbose bool) (reviewmodel.DiffReviewCreateResponse, error) {
148148
payload := reviewmodel.DiffReviewRequest{
149149
DiffZipBase64: base64Diff,
150150
RepoName: repoName,
151+
ToolsOnly: toolsOnly,
151152
}
152153

153154
if verbose {

internal/reviewmodel/types.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,28 @@ type APIErrorPayload struct {
3838
type DiffReviewRequest struct {
3939
DiffZipBase64 string `json:"diff_zip_base64"`
4040
RepoName string `json:"repo_name"`
41+
ToolsOnly bool `json:"tools_only,omitempty"`
42+
}
43+
44+
// ToolComment is a flat comment from a static analysis tool, including the file path.
45+
type ToolComment struct {
46+
FilePath string `json:"file_path"`
47+
Line int `json:"line"`
48+
Content string `json:"content"`
49+
Severity string `json:"severity"`
50+
Category string `json:"category"`
4151
}
4252

4353
// DiffReviewResponse models the response from GET /api/v1/diff-review/:id.
4454
type DiffReviewResponse struct {
4555
Status string `json:"status"`
4656
Summary string `json:"summary,omitempty"`
4757
Files []DiffReviewFileResult `json:"files,omitempty"`
58+
ToolComments []ToolComment `json:"tool_comments,omitempty"`
4859
Message string `json:"message,omitempty"`
4960
FriendlyName string `json:"friendly_name,omitempty"`
5061
Envelope *PlanUsageEnvelope `json:"envelope,omitempty"`
62+
ToolResults []ToolResultEventData `json:"tool_results,omitempty"`
5163
}
5264

5365
type DiffReviewCreateResponse struct {
@@ -90,3 +102,20 @@ type DiffReviewComment struct {
90102
Category string `json:"category"`
91103
Subcategory string `json:"subcategory"`
92104
}
105+
106+
type ToolFinding struct {
107+
File string `json:"file"`
108+
Line int `json:"line"`
109+
Col int `json:"col"`
110+
Rule string `json:"rule"`
111+
Message string `json:"message"`
112+
}
113+
114+
type ToolResultEventData struct {
115+
ToolID int64 `json:"tool_id"`
116+
ToolName string `json:"tool_name"`
117+
ExitCode int `json:"exit_code"`
118+
Findings []ToolFinding `json:"findings"`
119+
LinesOfCode int `json:"lines_of_code"`
120+
Stderr string `json:"stderr"`
121+
}

internal/reviewopts/options.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ type Options struct {
4343
Force bool
4444
Vouch bool
4545
InitialMsg string
46+
ToolsOnly bool
4647
}
4748

4849
func BuildFromContext(c *cli.Context, includeDebug bool) (Options, error) {
@@ -76,6 +77,7 @@ func BuildFromContext(c *cli.Context, includeDebug bool) (Options, error) {
7677
SaveJSON: c.String("save-json"),
7778
SaveText: c.String("save-text"),
7879
InitialMsg: initialMsg,
80+
ToolsOnly: c.Bool("tools"),
7981
}
8082

8183
if opts.Skip || opts.Vouch {

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ var baseFlags = []cli.Flag{
4242
&cli.BoolFlag{Name: "skip", Usage: "mark review as skipped and write attestation without contacting the API", EnvVars: []string{"LRC_SKIP"}},
4343
&cli.BoolFlag{Name: "force", Usage: "force rerun by removing existing attestation/hash for current tree", EnvVars: []string{"LRC_FORCE"}},
4444
&cli.BoolFlag{Name: "vouch", Usage: "vouch for changes manually without running AI review (records attestation with coverage stats from prior iterations)", EnvVars: []string{"LRC_VOUCH"}},
45+
&cli.BoolFlag{Name: "tools", Usage: "run only static analysis tools review, bypassing AI review", EnvVars: []string{"LRC_TOOLS"}},
4546
}
4647

4748
var debugFlags = []cli.Flag{

network/network_status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,7 @@ This document tracks network-side operations in git-lrc as an auditable inventor
7272
| Client.DoJSON | api | Request/response JSON payload bytes | Standard JSON HTTP call wrapper | Medium | Medium risk from broad transport usage and status-handling variance | Compensated by centralized transport wrapper with timeout controls; acceptable risk | [network/http_client.go](http_client.go#L43) |
7373
| Client.Do | api | Raw HTTP request/response bytes | Generic HTTP call wrapper for non-JSON/raw workflows | Medium | Medium risk from raw payload handling flexibility | Partially compensated by shared client boundary; Suggestion: document callsite expectations for raw bodies | [network/http_client.go](http_client.go#L89) |
7474
| SetupEnsureCloudUserURL | api | Base URL plus endpoint normalization inputs | Normalize endpoint composition and reduce path ambiguity | Medium | Medium risk if normalization logic diverges from endpoint assumptions | Compensated by centralized URL builder utility; acceptable risk | [network/endpoints.go](endpoints.go#L13) |
75-
| PollReview | api | Review IDs, status payloads, timeout state | Timeout-bounded polling orchestration in review runtime | High | High availability/latency risk if review service is degraded | Compensated by bounded timeout and interval controls; residual risk acceptable | [internal/reviewapi/helpers.go](../internal/reviewapi/helpers.go#L201) |
75+
| PollReview | api | Review IDs, status payloads, timeout state | Timeout-bounded polling orchestration in review runtime | High | High availability/latency risk if review service is degraded | Compensated by bounded timeout and interval controls; residual risk acceptable | [internal/reviewapi/helpers.go](../internal/reviewapi/helpers.go#L202) |
7676
| formatJSONParseError | api | Response body text for parse diagnostics | Improve operator diagnostics when endpoint/port mismatches occur | Low | Low risk diagnostic utility behavior | Compensated by safer error interpretation path; acceptable risk | [internal/reviewapi/helpers.go](../internal/reviewapi/helpers.go#L129) |
7777

7878
## Control Signals For Security Review

0 commit comments

Comments
 (0)