@inceptionstack/pi-hard-no 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dismiss.test.ts +117 -0
- package/dismiss.ts +136 -0
- package/index.ts +18 -1
- package/message-sender.ts +1 -1
- package/orchestrator.ts +31 -0
- package/package.json +1 -1
- package/reviewer.test.ts +46 -0
- package/reviewer.ts +2 -1
package/dismiss.test.ts
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { findingKey, numberFindings, parseDismissals, filterSuppressed, DismissTracker } from "./dismiss";
|
|
3
|
+
|
|
4
|
+
describe("findingKey", () => {
|
|
5
|
+
it("extracts severity + location from finding line", () => {
|
|
6
|
+
const line = '- **Medium:** src/gateway/model.ts:97 — chatId extraction uses wrong index';
|
|
7
|
+
const key = findingKey(line);
|
|
8
|
+
expect(key).toContain("medium:src/gateway/model.ts:97");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("handles numbered finding format (F# prefix)", () => {
|
|
12
|
+
const line = '- **F1 Medium:** src/foo.ts:10 — something bad';
|
|
13
|
+
const key = findingKey(line);
|
|
14
|
+
expect(key).toBe("medium:src/foo.ts:10 — something bad");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("falls back to trimmed line for non-matching format", () => {
|
|
18
|
+
const key = findingKey("some random text");
|
|
19
|
+
expect(key).toBe("some random text");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
describe("numberFindings", () => {
|
|
24
|
+
it("numbers finding bullets sequentially", () => {
|
|
25
|
+
const text = '- **High:** foo.ts:1 — bug\n- **Low:** bar.ts:2 — nit\nSome other text';
|
|
26
|
+
const { numbered, findings } = numberFindings(text);
|
|
27
|
+
expect(numbered).toContain("**F1 High:**");
|
|
28
|
+
expect(numbered).toContain("**F2 Low:**");
|
|
29
|
+
expect(numbered).toContain("Some other text");
|
|
30
|
+
expect(findings).toHaveLength(2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("preserves non-finding lines unchanged", () => {
|
|
34
|
+
const text = 'Header\n\n- **Medium:** x.ts:5 — issue\n\nFooter';
|
|
35
|
+
const { numbered } = numberFindings(text);
|
|
36
|
+
expect(numbered).toContain("Header");
|
|
37
|
+
expect(numbered).toContain("Footer");
|
|
38
|
+
expect(numbered).toContain("**F1 Medium:**");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("parseDismissals", () => {
|
|
43
|
+
it("parses DISMISS F# with colon separator", () => {
|
|
44
|
+
const text = "The chatId extraction is intentional.\nDISMISS F1: intentional design for telegram thread format";
|
|
45
|
+
const dismissals = parseDismissals(text);
|
|
46
|
+
expect(dismissals.size).toBe(1);
|
|
47
|
+
expect(dismissals.get(1)).toBe("intentional design for telegram thread format");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it("parses multiple dismissals", () => {
|
|
51
|
+
const text = "DISMISS F1: by design\nDISMISS F2: not a real issue";
|
|
52
|
+
const dismissals = parseDismissals(text);
|
|
53
|
+
expect(dismissals.size).toBe(2);
|
|
54
|
+
expect(dismissals.get(1)).toBe("by design");
|
|
55
|
+
expect(dismissals.get(2)).toBe("not a real issue");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("parses dash separator", () => {
|
|
59
|
+
const text = "DISMISS F3 - already reviewed";
|
|
60
|
+
const dismissals = parseDismissals(text);
|
|
61
|
+
expect(dismissals.get(3)).toBe("already reviewed");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("returns empty map when no dismissals", () => {
|
|
65
|
+
const text = "I fixed both issues as suggested.";
|
|
66
|
+
expect(parseDismissals(text).size).toBe(0);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("filterSuppressed", () => {
|
|
71
|
+
it("removes suppressed findings", () => {
|
|
72
|
+
const text = '- **High:** foo.ts:1 — bug one\n- **Low:** bar.ts:2 — nit two';
|
|
73
|
+
const suppressed = new Set([findingKey('- **High:** foo.ts:1 — bug one')]);
|
|
74
|
+
const result = filterSuppressed(text, suppressed);
|
|
75
|
+
expect(result).not.toContain("bug one");
|
|
76
|
+
expect(result).toContain("nit two");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("returns null when all findings suppressed", () => {
|
|
80
|
+
const text = '- **High:** foo.ts:1 — bug one';
|
|
81
|
+
const suppressed = new Set([findingKey('- **High:** foo.ts:1 — bug one')]);
|
|
82
|
+
expect(filterSuppressed(text, suppressed)).toBeNull();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns original when no suppressions", () => {
|
|
86
|
+
const text = '- **Low:** x.ts:5 — something';
|
|
87
|
+
expect(filterSuppressed(text, new Set())).toBe(text);
|
|
88
|
+
});
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("DismissTracker", () => {
|
|
92
|
+
it("tracks dismissals and suppresses after threshold", () => {
|
|
93
|
+
const tracker = new DismissTracker();
|
|
94
|
+
const findings = ['- **Medium:** src/foo.ts:10 — bad pattern', '- **Low:** src/bar.ts:5 — nit'];
|
|
95
|
+
tracker.setLastFindings(findings);
|
|
96
|
+
|
|
97
|
+
// First dismiss
|
|
98
|
+
tracker.processDismissals("DISMISS F1: intentional");
|
|
99
|
+
expect(tracker.getSuppressed().size).toBe(0); // threshold is 2
|
|
100
|
+
|
|
101
|
+
// Second dismiss (same finding via new review)
|
|
102
|
+
tracker.setLastFindings(findings);
|
|
103
|
+
tracker.processDismissals("DISMISS F1: still intentional");
|
|
104
|
+
expect(tracker.getSuppressed().size).toBe(1);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("reset clears all state", () => {
|
|
108
|
+
const tracker = new DismissTracker();
|
|
109
|
+
tracker.setLastFindings(['- **High:** x.ts:1 — bug']);
|
|
110
|
+
tracker.processDismissals("DISMISS F1: nope");
|
|
111
|
+
tracker.processDismissals("DISMISS F1: nope again");
|
|
112
|
+
expect(tracker.getSuppressed().size).toBe(1);
|
|
113
|
+
|
|
114
|
+
tracker.reset();
|
|
115
|
+
expect(tracker.getSuppressed().size).toBe(0);
|
|
116
|
+
});
|
|
117
|
+
});
|
package/dismiss.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dismiss.ts — Track and suppress dismissed review findings
|
|
3
|
+
*
|
|
4
|
+
* When the agent responds to a review with "DISMISS F#: reason",
|
|
5
|
+
* that finding is tracked. If the same finding appears in a subsequent
|
|
6
|
+
* review cycle and has been dismissed >= threshold times, it's suppressed.
|
|
7
|
+
*
|
|
8
|
+
* Finding identity: severity + file:line + first 60 chars of problem text.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { log } from "./logger";
|
|
12
|
+
|
|
13
|
+
export interface DismissedFinding {
|
|
14
|
+
key: string;
|
|
15
|
+
reason: string;
|
|
16
|
+
count: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** How many times a finding must be dismissed before it's auto-suppressed. */
|
|
20
|
+
const SUPPRESS_THRESHOLD = 2;
|
|
21
|
+
|
|
22
|
+
/** Extract a stable key from a finding bullet line. */
|
|
23
|
+
export function findingKey(line: string): string {
|
|
24
|
+
// Format: - **Severity:** file:line — problem text
|
|
25
|
+
const match = line.match(/^\s*-\s*\*\*(?:F\d+\s+)?(\w+):\*\*\s*(.+)/);
|
|
26
|
+
if (!match) return line.trim().slice(0, 80);
|
|
27
|
+
const severity = match[1].toLowerCase();
|
|
28
|
+
const rest = match[2].trim().slice(0, 60);
|
|
29
|
+
return `${severity}:${rest}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Number findings in review text and return the numbered version + finding list. */
|
|
33
|
+
export function numberFindings(text: string): { numbered: string; findings: string[] } {
|
|
34
|
+
const lines = text.split("\n");
|
|
35
|
+
const findings: string[] = [];
|
|
36
|
+
let counter = 0;
|
|
37
|
+
|
|
38
|
+
const numbered = lines.map(line => {
|
|
39
|
+
// Match finding bullets: - **Severity:** ...
|
|
40
|
+
const match = line.match(/^(\s*-\s*)\*\*(\w+):\*\*(.*)$/);
|
|
41
|
+
if (match) {
|
|
42
|
+
counter++;
|
|
43
|
+
findings.push(line);
|
|
44
|
+
return `${match[1]}**F${counter} ${match[2]}:**${match[3]}`;
|
|
45
|
+
}
|
|
46
|
+
return line;
|
|
47
|
+
}).join("\n");
|
|
48
|
+
|
|
49
|
+
return { numbered, findings };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Parse DISMISS markers from agent text. Returns map of F# → reason. */
|
|
53
|
+
export function parseDismissals(text: string): Map<number, string> {
|
|
54
|
+
const dismissals = new Map<number, string>();
|
|
55
|
+
// Match: DISMISS F1: reason or DISMISS F1 - reason or DISMISS F1 reason
|
|
56
|
+
const pattern = /DISMISS\s+F(\d+)\s*[:–\-]\s*(.+)/gi;
|
|
57
|
+
let match;
|
|
58
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
59
|
+
dismissals.set(parseInt(match[1], 10), match[2].trim());
|
|
60
|
+
}
|
|
61
|
+
return dismissals;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Filter suppressed findings from review text. Returns filtered text or null if all suppressed. */
|
|
65
|
+
export function filterSuppressed(text: string, suppressed: Set<string>): string | null {
|
|
66
|
+
if (suppressed.size === 0) return text;
|
|
67
|
+
|
|
68
|
+
const lines = text.split("\n");
|
|
69
|
+
const filtered = lines.filter(line => {
|
|
70
|
+
const match = line.match(/^\s*-\s*\*\*\w+:\*\*/);
|
|
71
|
+
if (!match) return true; // keep non-finding lines
|
|
72
|
+
const key = findingKey(line);
|
|
73
|
+
return !suppressed.has(key);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// If all findings were suppressed, return null (should be LGTM)
|
|
77
|
+
const remaining = filtered.filter(l => l.match(/^\s*-\s*\*\*/));
|
|
78
|
+
if (remaining.length === 0) return null;
|
|
79
|
+
|
|
80
|
+
return filtered.join("\n");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Dismiss tracker — stores dismissed findings across review loops.
|
|
85
|
+
* Scoped to one orchestrator instance (one session).
|
|
86
|
+
*/
|
|
87
|
+
export class DismissTracker {
|
|
88
|
+
private dismissed = new Map<string, DismissedFinding>();
|
|
89
|
+
private lastFindings: string[] = [];
|
|
90
|
+
|
|
91
|
+
/** Record the findings from the latest review (for F# → finding mapping). */
|
|
92
|
+
setLastFindings(findings: string[]): void {
|
|
93
|
+
this.lastFindings = findings;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Process agent's response text for DISMISS markers. */
|
|
97
|
+
processDismissals(agentText: string): number {
|
|
98
|
+
const markers = parseDismissals(agentText);
|
|
99
|
+
if (markers.size === 0) return 0;
|
|
100
|
+
|
|
101
|
+
let count = 0;
|
|
102
|
+
for (const [fNum, reason] of markers) {
|
|
103
|
+
const finding = this.lastFindings[fNum - 1]; // F1 = index 0
|
|
104
|
+
if (!finding) continue;
|
|
105
|
+
|
|
106
|
+
const key = findingKey(finding);
|
|
107
|
+
const existing = this.dismissed.get(key);
|
|
108
|
+
if (existing) {
|
|
109
|
+
existing.count++;
|
|
110
|
+
existing.reason = reason;
|
|
111
|
+
} else {
|
|
112
|
+
this.dismissed.set(key, { key, reason, count: 1 });
|
|
113
|
+
}
|
|
114
|
+
count++;
|
|
115
|
+
log(`dismiss: F${fNum} dismissed (${key}) — "${reason}" [count=${this.dismissed.get(key)!.count}]`);
|
|
116
|
+
}
|
|
117
|
+
return count;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Get the set of finding keys that should be suppressed (dismissed >= threshold). */
|
|
121
|
+
getSuppressed(): Set<string> {
|
|
122
|
+
const suppressed = new Set<string>();
|
|
123
|
+
for (const [key, entry] of this.dismissed) {
|
|
124
|
+
if (entry.count >= SUPPRESS_THRESHOLD) {
|
|
125
|
+
suppressed.add(key);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return suppressed;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Reset all dismissals (e.g. on session end). */
|
|
132
|
+
reset(): void {
|
|
133
|
+
this.dismissed.clear();
|
|
134
|
+
this.lastFindings = [];
|
|
135
|
+
}
|
|
136
|
+
}
|
package/index.ts
CHANGED
|
@@ -615,10 +615,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
615
615
|
const hasArchitectStep = Boolean(outcome.architect);
|
|
616
616
|
const hasArchitectFailure = Boolean(outcome.architectFailure);
|
|
617
617
|
const hasFollowUp = hasArchitectStep || hasArchitectFailure;
|
|
618
|
+
// Apply dismiss filtering (suppress previously dismissed findings, number remaining)
|
|
619
|
+
const filteredResult = orchestrator.applyDismissFiltering(outcome.senior.result);
|
|
620
|
+
|
|
621
|
+
// If dismiss filtering converted issues to LGTM, clear the issues state
|
|
622
|
+
if (filteredResult.isLgtm && !outcome.senior.result.isLgtm) {
|
|
623
|
+
orchestrator.clearIssuesAfterDismiss();
|
|
624
|
+
}
|
|
625
|
+
|
|
618
626
|
// Always trigger a turn for ISSUES_FOUND so agent can fix.
|
|
619
627
|
// Also trigger for LGTM so agent can continue (push, etc.).
|
|
620
628
|
// Skip triggering only when architect (success or failure) follows — it sends its own message.
|
|
621
|
-
sendReviewResult(pi,
|
|
629
|
+
sendReviewResult(pi, filteredResult, outcome.senior.label ?? "", {
|
|
622
630
|
showLoopCount: outcome.senior.loopInfo,
|
|
623
631
|
reviewedFiles: outcome.files,
|
|
624
632
|
triggerTurn: !hasFollowUp,
|
|
@@ -696,6 +704,15 @@ export default function (pi: ExtensionAPI) {
|
|
|
696
704
|
return;
|
|
697
705
|
}
|
|
698
706
|
|
|
707
|
+
// Process DISMISS markers from agent's response (before running review)
|
|
708
|
+
if (lastAssistant) {
|
|
709
|
+
const textParts = (lastAssistant.content ?? []).filter((b: any) => b.type === "text").map((b: any) => b.text);
|
|
710
|
+
const agentText = textParts.join("\n");
|
|
711
|
+
if (agentText) {
|
|
712
|
+
orchestrator.processDismissals(agentText);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
|
|
699
716
|
if (!orchestrator.isEnabled) {
|
|
700
717
|
// Keep tracking state (modifiedFiles, agentToolCalls) so we can
|
|
701
718
|
// offer to review when the user toggles review back on.
|
package/message-sender.ts
CHANGED
|
@@ -74,7 +74,7 @@ export function sendReviewResult(
|
|
|
74
74
|
pi.sendMessage(
|
|
75
75
|
{
|
|
76
76
|
customType: "code-review",
|
|
77
|
-
content: `🔍 **Automated Code Review**${loopInfo || (label ? ` (${label})` : "")} — ${duration}\n\nA separate reviewer examined your recent changes and found potential issues:\n\n${result.text}${fileList}${idFooter}\n\nPlease review these findings. If any are valid, fix them. If they're false positives,
|
|
77
|
+
content: `🔍 **Automated Code Review**${loopInfo || (label ? ` (${label})` : "")} — ${duration}\n\nA separate reviewer examined your recent changes and found potential issues:\n\n${result.text}${fileList}${idFooter}\n\nPlease review these findings. If any are valid, fix them. If they're false positives, reply with \`DISMISS F#: reason\` (e.g. \`DISMISS F1: intentional design\`) and move on.\n\n⚠️ **Do NOT push to remote yet.** Fix any issues first. Do NOT push after fixing either — a new review cycle will check your fixes automatically.`,
|
|
78
78
|
display: true,
|
|
79
79
|
},
|
|
80
80
|
{ triggerTurn: opts?.triggerTurn ?? true, deliverAs: "followUp" },
|
package/orchestrator.ts
CHANGED
|
@@ -10,6 +10,7 @@ import type { AutoReviewSettings } from "./settings";
|
|
|
10
10
|
import { runArchitectReview, shouldRunArchitectReview } from "./architect";
|
|
11
11
|
import type { ReviewResult, ReviewRunner } from "./reviewer";
|
|
12
12
|
import { log } from "./logger";
|
|
13
|
+
import { DismissTracker, numberFindings, filterSuppressed } from "./dismiss";
|
|
13
14
|
|
|
14
15
|
const MIN_REVIEW_CONTENT_LENGTH = 50;
|
|
15
16
|
|
|
@@ -110,6 +111,7 @@ export class ReviewOrchestrator {
|
|
|
110
111
|
private sessionChangedFiles = new Set<string>();
|
|
111
112
|
private sessionHasGitContent = false;
|
|
112
113
|
private lastReviewHadIssues = false;
|
|
114
|
+
private readonly dismissTracker = new DismissTracker();
|
|
113
115
|
|
|
114
116
|
constructor(opts: ReviewOrchestratorOptions) {
|
|
115
117
|
this.runner = opts.runner;
|
|
@@ -148,6 +150,35 @@ export class ReviewOrchestrator {
|
|
|
148
150
|
this.isReviewingValue = false;
|
|
149
151
|
this.resetCycleState();
|
|
150
152
|
this.lastReviewHadIssues = false;
|
|
153
|
+
this.dismissTracker.reset();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Process agent's response text for DISMISS markers. Call before review. */
|
|
157
|
+
processDismissals(agentText: string): number {
|
|
158
|
+
return this.dismissTracker.processDismissals(agentText);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Apply dismiss filtering + numbering to review result text. */
|
|
162
|
+
applyDismissFiltering(result: ReviewResult): ReviewResult {
|
|
163
|
+
const suppressed = this.dismissTracker.getSuppressed();
|
|
164
|
+
const filtered = filterSuppressed(result.text, suppressed);
|
|
165
|
+
|
|
166
|
+
// All findings suppressed — treat as LGTM
|
|
167
|
+
if (filtered === null) {
|
|
168
|
+
log("dismiss: all findings suppressed — treating as LGTM");
|
|
169
|
+
return { ...result, isLgtm: true, text: "No issues found (previously dismissed findings suppressed)." };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Number remaining findings and track them
|
|
173
|
+
const { numbered, findings } = numberFindings(filtered);
|
|
174
|
+
this.dismissTracker.setLastFindings(findings);
|
|
175
|
+
return { ...result, text: numbered };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Clear issue state after dismiss filtering converts to LGTM. */
|
|
179
|
+
clearIssuesAfterDismiss(): void {
|
|
180
|
+
this.lastReviewHadIssues = false;
|
|
181
|
+
this.loopCount = 0;
|
|
151
182
|
}
|
|
152
183
|
|
|
153
184
|
cancel(): void {
|
package/package.json
CHANGED
package/reviewer.test.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseVerdict, cleanReviewText, isLgtmResult } from "./reviewer";
|
|
3
|
+
|
|
4
|
+
describe("reviewer verdict parsing", () => {
|
|
5
|
+
it("parseVerdict extracts LGTM", () => {
|
|
6
|
+
expect(parseVerdict("some text <verdict>LGTM</verdict> more")).toBe("lgtm");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("parseVerdict extracts ISSUES_FOUND", () => {
|
|
10
|
+
expect(parseVerdict("<verdict>ISSUES_FOUND</verdict>")).toBe("issues");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("parseVerdict returns null when no tag", () => {
|
|
14
|
+
expect(parseVerdict("no verdict here")).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("isLgtmResult returns true for empty text", () => {
|
|
18
|
+
expect(isLgtmResult("")).toBe(true);
|
|
19
|
+
expect(isLgtmResult(" ")).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("isLgtmResult returns false when severity markers present", () => {
|
|
23
|
+
expect(isLgtmResult("- **High:** something bad")).toBe(false);
|
|
24
|
+
expect(isLgtmResult("- **Medium:** fix this")).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("isLgtmResult returns true for explicit LGTM text", () => {
|
|
28
|
+
expect(isLgtmResult("LGTM — looks good")).toBe(true);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("cleanReviewText strips verdict tags", () => {
|
|
32
|
+
const raw = "Some findings\n<verdict>ISSUES_FOUND</verdict>";
|
|
33
|
+
expect(cleanReviewText(raw)).toBe("Some findings");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("empty cleanedText with ISSUES_FOUND verdict should be treated as LGTM", () => {
|
|
37
|
+
// This tests the bug where model returns <verdict>ISSUES_FOUND</verdict>
|
|
38
|
+
// with no actual findings text — resulting in confusing "found potential issues:" + nothing
|
|
39
|
+
const raw = "<verdict>ISSUES_FOUND</verdict>";
|
|
40
|
+
const cleaned = cleanReviewText(raw);
|
|
41
|
+
expect(cleaned).toBe("");
|
|
42
|
+
// The fix: verdict=issues + empty cleaned = treat as LGTM
|
|
43
|
+
// (tested at integration level in reviewer.ts line ~395)
|
|
44
|
+
expect(isLgtmResult(cleaned)).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
package/reviewer.ts
CHANGED
|
@@ -392,7 +392,8 @@ export async function runReviewSession(prompt: string, opts: ReviewOptions): Pro
|
|
|
392
392
|
}
|
|
393
393
|
|
|
394
394
|
const cleanedText = cleanReviewText(reviewText);
|
|
395
|
-
|
|
395
|
+
// If model said ISSUES_FOUND but produced no actual findings text, treat as LGTM
|
|
396
|
+
const isLgtm = verdict === "lgtm" || (verdict === "issues" && !cleanedText.trim());
|
|
396
397
|
const durationMs = Date.now() - startTime;
|
|
397
398
|
|
|
398
399
|
rlog(
|