@mechanai/deepreview 1.0.1 → 2.0.1

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.
Files changed (32) hide show
  1. package/.opencode/agents/deepreview-review-formatter.md +66 -0
  2. package/.opencode/commands/_deepreview-pipeline.md +57 -0
  3. package/.opencode/commands/deepreview-pr-review.md +61 -0
  4. package/.opencode/plugins/deepreview.ts +45 -0
  5. package/README.md +48 -100
  6. package/package.json +29 -6
  7. package/src/diff-classifier.test.ts +45 -0
  8. package/src/diff-classifier.ts +63 -0
  9. package/src/graphql.ts +183 -0
  10. package/src/parse-threads.test.ts +58 -0
  11. package/src/parse-threads.ts +117 -0
  12. package/src/post-review.test.ts +52 -0
  13. package/src/post-review.ts +325 -0
  14. package/src/review-api.ts +253 -0
  15. package/src/review-helpers.ts +65 -0
  16. package/src/cli.js +0 -103
  17. /package/{agents → .opencode/agents}/deepreview-applier.md +0 -0
  18. /package/{agents → .opencode/agents}/deepreview-architecture.md +0 -0
  19. /package/{agents → .opencode/agents}/deepreview-compatibility.md +0 -0
  20. /package/{agents → .opencode/agents}/deepreview-correctness.md +0 -0
  21. /package/{agents → .opencode/agents}/deepreview-docs.md +0 -0
  22. /package/{agents → .opencode/agents}/deepreview-planner.md +0 -0
  23. /package/{agents → .opencode/agents}/deepreview-security.md +0 -0
  24. /package/{agents → .opencode/agents}/deepreview-spec-completeness.md +0 -0
  25. /package/{agents → .opencode/agents}/deepreview-spec-consistency.md +0 -0
  26. /package/{agents → .opencode/agents}/deepreview-spec-feasibility.md +0 -0
  27. /package/{agents → .opencode/agents}/deepreview-synthesizer.md +0 -0
  28. /package/{agents → .opencode/agents}/deepreview-validator.md +0 -0
  29. /package/{commands → .opencode/commands}/deepreview-loop.md +0 -0
  30. /package/{commands → .opencode/commands}/deepreview-spec-loop.md +0 -0
  31. /package/{commands → .opencode/commands}/deepreview-spec.md +0 -0
  32. /package/{commands → .opencode/commands}/deepreview.md +0 -0
@@ -0,0 +1,325 @@
1
+ import { readFile, realpath } from "node:fs/promises";
2
+ import { resolve } from "node:path";
3
+ import { parseThreads } from "./parse-threads.ts";
4
+ import { classifyFindings } from "./diff-classifier.ts";
5
+ import type { Finding, ClassifiedFinding } from "./diff-classifier.ts";
6
+ import { type PrInfo, getPrInfo, execFileAsync } from "./graphql.ts";
7
+ import {
8
+ type PendingReview,
9
+ findPendingReview,
10
+ createPendingReview,
11
+ updateReviewBody,
12
+ addLineThread,
13
+ addFileThread,
14
+ updateReviewComment,
15
+ } from "./review-api.ts";
16
+ import {
17
+ escapeHtml,
18
+ isValidPath,
19
+ isRateLimitError,
20
+ findingId,
21
+ embedFindingId,
22
+ extractFindingId,
23
+ } from "./review-helpers.ts";
24
+
25
+ export interface PostReviewOptions {
26
+ threadsPath: string;
27
+ prNumber: number;
28
+ dryRun?: boolean;
29
+ skipIds?: string[];
30
+ expectedSha?: string;
31
+ cwd?: string;
32
+ diffText?: string;
33
+ }
34
+
35
+ export interface PostReviewResult {
36
+ summary: string;
37
+ failed: string[];
38
+ }
39
+
40
+ const AI_TRAILER = "\n\n---\n🤖 Review generated by AI";
41
+
42
+ function buildReviewBody(
43
+ tier3Findings: ClassifiedFinding[],
44
+ owner: string,
45
+ name: string,
46
+ headOid: string,
47
+ ): string {
48
+ let body = "";
49
+ for (const f of tier3Findings) {
50
+ if (!isValidPath(f.path)) {
51
+ console.warn(`WARN: Skipping finding with suspicious path: ${f.path}`);
52
+ continue;
53
+ }
54
+ const permalink = `https://github.com/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/blob/${headOid}/${f.path.split("/").map(encodeURIComponent).join("/")}#L${f.line}`;
55
+ // Neutralize all HTML tags — GitHub re-renders markdown inside <details>.
56
+ body += `<details>\n<summary><a href="${permalink}"><code>${escapeHtml(f.path)}:${f.line}</code></a></summary>\n\n${f.body}\n</details>\n\n`;
57
+ }
58
+ body += AI_TRAILER;
59
+ return body;
60
+ }
61
+
62
+ async function updateExistingThreads(
63
+ existingReview: PendingReview,
64
+ findings: ClassifiedFinding[],
65
+ ): Promise<Set<string>> {
66
+ const existingComments = existingReview.comments.nodes;
67
+ const existingById = new Map<string, { id: string; body: string }>();
68
+ for (const c of existingComments) {
69
+ const id = extractFindingId(c.body);
70
+ if (id !== null) existingById.set(id, c);
71
+ }
72
+
73
+ const toUpdate: Array<{ finding: ClassifiedFinding; commentId: string; id: string }> = [];
74
+ for (const f of findings) {
75
+ const id = findingId(f.path, f.startLine, f.line, f.body);
76
+ const existing = existingById.get(id);
77
+ if (existing) {
78
+ toUpdate.push({ finding: f, commentId: existing.id, id });
79
+ }
80
+ }
81
+
82
+ for (const { finding, commentId, id } of toUpdate) {
83
+ const body = embedFindingId(finding.body, id);
84
+ await updateReviewComment(commentId, body);
85
+ }
86
+
87
+ console.log(`Updated ${toUpdate.length} existing threads.`);
88
+ return new Set(toUpdate.map(({ id }) => id));
89
+ }
90
+
91
+ async function postThread(
92
+ reviewId: string,
93
+ finding: ClassifiedFinding,
94
+ body: string,
95
+ ): Promise<void> {
96
+ if (finding.tier === 1) {
97
+ await addLineThread(reviewId, finding.path, finding.line, finding.startLine, body);
98
+ } else {
99
+ await addFileThread(reviewId, finding.path, body);
100
+ }
101
+ }
102
+
103
+ async function postWithRetry(
104
+ reviewId: string,
105
+ finding: ClassifiedFinding,
106
+ body: string,
107
+ ): Promise<boolean> {
108
+ try {
109
+ await postThread(reviewId, finding, body);
110
+ return true;
111
+ } catch (err: unknown) {
112
+ if (!isRateLimitError(err)) {
113
+ const message = err instanceof Error ? err.message : String(err);
114
+ console.error(`FAIL: ${finding.path}:${finding.line} — ${message}`);
115
+ return false;
116
+ }
117
+ for (let attempt = 0; attempt < 2; attempt++) {
118
+ console.warn(
119
+ `Rate limited on ${finding.path}:${finding.line}. Waiting 60s (retry ${attempt + 1}/2)...`,
120
+ );
121
+ await Bun.sleep(60_000 + Math.floor(Math.random() * 10_000));
122
+ try {
123
+ await postThread(reviewId, finding, body);
124
+ return true;
125
+ } catch (retryErr: unknown) {
126
+ if (!isRateLimitError(retryErr)) {
127
+ const message = retryErr instanceof Error ? retryErr.message : String(retryErr);
128
+ console.error(`FAIL: ${finding.path}:${finding.line} — ${message}`);
129
+ return false;
130
+ }
131
+ }
132
+ }
133
+ console.error(`FAIL: ${finding.path}:${finding.line} — rate limited after retries`);
134
+ return false;
135
+ }
136
+ }
137
+
138
+ async function postInlineThreads(
139
+ reviewId: string,
140
+ inlineFindings: ClassifiedFinding[],
141
+ updatedIds: Set<string>,
142
+ ): Promise<string[]> {
143
+ const CONCURRENCY = 2;
144
+ const failedFindings: string[] = [];
145
+ const pending = inlineFindings.filter(
146
+ (f) => !updatedIds.has(findingId(f.path, f.startLine, f.line, f.body)),
147
+ );
148
+
149
+ for (let i = 0; i < pending.length; i += CONCURRENCY) {
150
+ if (i > 0) await Bun.sleep(1000);
151
+ const batch = pending.slice(i, i + CONCURRENCY);
152
+ const results = await Promise.all(
153
+ batch.map(async (f) => {
154
+ const id = findingId(f.path, f.startLine, f.line, f.body);
155
+ // Trust boundary: body content is rendered by GitHub's Markdown sanitizer
156
+ const body = embedFindingId(f.body, id);
157
+ const ok = await postWithRetry(reviewId, f, body);
158
+ return ok ? null : id;
159
+ }),
160
+ );
161
+ for (const id of results) {
162
+ if (id !== null) failedFindings.push(id);
163
+ }
164
+ }
165
+ return failedFindings;
166
+ }
167
+
168
+ function classifyAndLog(
169
+ findings: Finding[],
170
+ diff: string,
171
+ ): { tier1: ClassifiedFinding[]; tier2: ClassifiedFinding[]; tier3: ClassifiedFinding[] } {
172
+ const validated = findings.filter((f) => {
173
+ if (!isValidPath(f.path)) {
174
+ console.warn(`WARN: Skipping finding with suspicious path: ${f.path}`);
175
+ return false;
176
+ }
177
+ return true;
178
+ });
179
+ const classified = classifyFindings(validated, diff);
180
+ const tier1 = classified.filter((f) => f.tier === 1);
181
+ const tier2 = classified.filter((f) => f.tier === 2);
182
+ const tier3 = classified.filter((f) => f.tier === 3);
183
+
184
+ for (const f of tier2) {
185
+ console.warn(`WARN: ${f.path}:${f.line} demoted to file-level`);
186
+ }
187
+ for (const f of tier3) {
188
+ console.warn(`WARN: ${f.path}:${f.line} demoted to review body`);
189
+ }
190
+ return { tier1, tier2, tier3 };
191
+ }
192
+
193
+ async function ensureReview(
194
+ prNodeId: string,
195
+ headOid: string,
196
+ tier1: ClassifiedFinding[],
197
+ tier2: ClassifiedFinding[],
198
+ tier3: ClassifiedFinding[],
199
+ owner: string,
200
+ name: string,
201
+ ): Promise<{ reviewId: string; updatedFindings: Set<string> }> {
202
+ const existingReview = await findPendingReview(prNodeId);
203
+ if (existingReview) {
204
+ console.log(`Found existing pending review: ${existingReview.id}`);
205
+ await updateReviewBody(existingReview.id, buildReviewBody(tier3, owner, name, headOid));
206
+ const updated = await updateExistingThreads(existingReview, [...tier1, ...tier2]);
207
+ return { reviewId: existingReview.id, updatedFindings: updated };
208
+ }
209
+ const reviewId = await createPendingReview(
210
+ prNodeId,
211
+ headOid,
212
+ buildReviewBody(tier3, owner, name, headOid),
213
+ );
214
+ console.log(`Created pending review: ${reviewId}`);
215
+ return { reviewId, updatedFindings: new Set() };
216
+ }
217
+
218
+ async function loadAndValidatePr(
219
+ threadsPath: string,
220
+ prNumber: number,
221
+ expectedSha: string | undefined,
222
+ cwd: string | undefined,
223
+ ): Promise<{ findings: Finding[]; prInfo: PrInfo } | null> {
224
+ if (!isValidPath(threadsPath)) {
225
+ throw new Error(`Invalid threadsPath: ${threadsPath}`);
226
+ }
227
+ const base = await realpath(resolve(cwd ?? process.cwd()));
228
+ const candidatePath = resolve(base, threadsPath);
229
+ let resolved: string;
230
+ try {
231
+ resolved = await realpath(candidatePath);
232
+ } catch {
233
+ throw new Error(`Threads file not found: ${candidatePath}`);
234
+ }
235
+ if (!resolved.startsWith(base + "/") && resolved !== base) {
236
+ throw new Error(`threadsPath escapes working directory: ${threadsPath}`);
237
+ }
238
+ const content = await readFile(resolved, "utf8");
239
+ const findings = parseThreads(content);
240
+ if (findings.length === 0) return null;
241
+
242
+ const prInfo = await getPrInfo(prNumber, { cwd });
243
+ if (prInfo.state !== "OPEN") {
244
+ throw new Error(`PR is ${prInfo.state}. Aborting.`);
245
+ }
246
+
247
+ const resolvedSha = expectedSha ?? process.env.PR_HEAD_SHA;
248
+ if (resolvedSha !== undefined && resolvedSha !== "" && resolvedSha !== prInfo.headOid) {
249
+ throw new Error(`ABORT: PR head moved (expected ${resolvedSha}, got ${prInfo.headOid}).`);
250
+ }
251
+
252
+ return { findings, prInfo };
253
+ }
254
+
255
+ async function postFindings(
256
+ tier1: ClassifiedFinding[],
257
+ tier2: ClassifiedFinding[],
258
+ tier3: ClassifiedFinding[],
259
+ prInfo: PrInfo,
260
+ skipIds: string[],
261
+ ): Promise<PostReviewResult> {
262
+ const { owner, name, prNodeId, headOid } = prInfo;
263
+ const { reviewId, updatedFindings } = await ensureReview(
264
+ prNodeId,
265
+ headOid,
266
+ tier1,
267
+ tier2,
268
+ tier3,
269
+ owner,
270
+ name,
271
+ );
272
+
273
+ const skipSet = new Set(skipIds);
274
+ const inlineFindings = [...tier1, ...tier2].filter((f) => {
275
+ const id = findingId(f.path, f.startLine, f.line, f.body);
276
+ return !skipSet.has(id);
277
+ });
278
+
279
+ const failedIds = await postInlineThreads(reviewId, inlineFindings, updatedFindings);
280
+ const skipped = inlineFindings.filter((f) =>
281
+ updatedFindings.has(findingId(f.path, f.startLine, f.line, f.body)),
282
+ ).length;
283
+ const posted = inlineFindings.length - skipped - failedIds.length;
284
+ const totalFindings = [...tier1, ...tier2].length;
285
+ const skippedByUser = totalFindings - inlineFindings.length;
286
+ const summary =
287
+ `Posted ${posted}/${totalFindings} inline threads (${skipped} up-to-date, ${skippedByUser} skipped, ${failedIds.length} failed). ` +
288
+ `${tier3.length} findings in review body.`;
289
+
290
+ console.log(summary);
291
+
292
+ return { summary, failed: failedIds };
293
+ }
294
+
295
+ /**
296
+ * Post a review to a GitHub PR.
297
+ */
298
+ export async function postReview(opts: PostReviewOptions): Promise<PostReviewResult> {
299
+ const { threadsPath, prNumber, dryRun = false, skipIds = [], expectedSha } = opts;
300
+
301
+ const result = await loadAndValidatePr(threadsPath, prNumber, expectedSha, opts.cwd);
302
+ if (!result) return { summary: "No findings to post.", failed: [] };
303
+
304
+ const { findings, prInfo } = result;
305
+ const diff =
306
+ opts.diffText ??
307
+ (
308
+ await execFileAsync("gh", ["pr", "diff", String(prNumber)], {
309
+ encoding: "utf8",
310
+ maxBuffer: 10 * 1024 * 1024,
311
+ signal: AbortSignal.timeout(30_000),
312
+ cwd: opts.cwd,
313
+ })
314
+ ).stdout;
315
+ const { tier1, tier2, tier3 } = classifyAndLog(findings, diff);
316
+
317
+ if (dryRun) {
318
+ return {
319
+ summary: `Dry run: ${tier1.length} line-level, ${tier2.length} file-level, ${tier3.length} review-body findings.`,
320
+ failed: [],
321
+ };
322
+ }
323
+
324
+ return postFindings(tier1, tier2, tier3, prInfo, skipIds);
325
+ }
@@ -0,0 +1,253 @@
1
+ import { graphql } from "./graphql.ts";
2
+
3
+ const MAX_PAGES = 50;
4
+
5
+ interface PageInfo {
6
+ hasNextPage: boolean;
7
+ endCursor: string;
8
+ }
9
+
10
+ export interface ReviewComment {
11
+ id: string;
12
+ body: string;
13
+ path: string;
14
+ startLine: number | null;
15
+ line: number;
16
+ }
17
+
18
+ export interface PendingReview {
19
+ id: string;
20
+ body: string;
21
+ comments: {
22
+ totalCount: number;
23
+ nodes: ReviewComment[];
24
+ };
25
+ }
26
+
27
+ interface CommentsPage {
28
+ node: {
29
+ comments: {
30
+ pageInfo: PageInfo;
31
+ nodes: ReviewComment[];
32
+ };
33
+ };
34
+ }
35
+
36
+ interface FindPendingReviewResponse {
37
+ node: {
38
+ viewerLatestReview: {
39
+ nodes: Array<{
40
+ id: string;
41
+ body: string;
42
+ comments: {
43
+ totalCount: number;
44
+ pageInfo: PageInfo;
45
+ nodes: ReviewComment[];
46
+ };
47
+ }>;
48
+ };
49
+ };
50
+ }
51
+
52
+ interface CreateReviewResponse {
53
+ addPullRequestReview: {
54
+ pullRequestReview: {
55
+ id: string;
56
+ };
57
+ };
58
+ }
59
+
60
+ async function fetchRemainingComments(
61
+ reviewId: string,
62
+ initialPageInfo: PageInfo,
63
+ ): Promise<ReviewComment[]> {
64
+ const comments: ReviewComment[] = [];
65
+ let pageInfo = initialPageInfo;
66
+ let pages = 0;
67
+ while (pageInfo.hasNextPage && pages++ < MAX_PAGES) {
68
+ const page = await graphql<CommentsPage>(
69
+ `
70
+ query ($reviewId: ID!, $after: String!) {
71
+ node(id: $reviewId) {
72
+ ... on PullRequestReview {
73
+ comments(first: 100, after: $after) {
74
+ pageInfo {
75
+ hasNextPage
76
+ endCursor
77
+ }
78
+ nodes {
79
+ id
80
+ body
81
+ path
82
+ startLine
83
+ line
84
+ }
85
+ }
86
+ }
87
+ }
88
+ }
89
+ `,
90
+ { reviewId, after: pageInfo.endCursor },
91
+ );
92
+ const next = page.node.comments;
93
+ comments.push(...next.nodes);
94
+ pageInfo = next.pageInfo;
95
+ }
96
+ if (pages >= MAX_PAGES) {
97
+ console.warn(`WARN: Reached MAX_PAGES (${MAX_PAGES}) — review data may be incomplete.`);
98
+ }
99
+ return comments;
100
+ }
101
+
102
+ export async function findPendingReview(prNodeId: string): Promise<PendingReview | null> {
103
+ const data = await graphql<FindPendingReviewResponse>(
104
+ `
105
+ query ($prId: ID!) {
106
+ node(id: $prId) {
107
+ ... on PullRequest {
108
+ viewerLatestReview: reviews(last: 1, states: PENDING, author: "@me") {
109
+ nodes {
110
+ id
111
+ body
112
+ comments(first: 100) {
113
+ totalCount
114
+ pageInfo {
115
+ hasNextPage
116
+ endCursor
117
+ }
118
+ nodes {
119
+ id
120
+ body
121
+ path
122
+ startLine
123
+ line
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ }
131
+ `,
132
+ { prId: prNodeId },
133
+ );
134
+ const reviews = data.node.viewerLatestReview.nodes;
135
+ if (reviews.length === 0) return null;
136
+
137
+ const review = reviews[0];
138
+ const comments: ReviewComment[] = [...review.comments.nodes];
139
+ const remaining = await fetchRemainingComments(review.id, review.comments.pageInfo);
140
+ comments.push(...remaining);
141
+
142
+ return { ...review, comments: { totalCount: review.comments.totalCount, nodes: comments } };
143
+ }
144
+
145
+ export async function createPendingReview(
146
+ prNodeId: string,
147
+ commitOid: string,
148
+ body: string,
149
+ ): Promise<string> {
150
+ const data = await graphql<CreateReviewResponse>(
151
+ `
152
+ mutation ($input: AddPullRequestReviewInput!) {
153
+ addPullRequestReview(input: $input) {
154
+ pullRequestReview {
155
+ id
156
+ }
157
+ }
158
+ }
159
+ `,
160
+ {
161
+ input: {
162
+ pullRequestId: prNodeId,
163
+ commitOID: commitOid,
164
+ body,
165
+ },
166
+ },
167
+ );
168
+ return data.addPullRequestReview.pullRequestReview.id;
169
+ }
170
+
171
+ export async function updateReviewBody(reviewId: string, body: string): Promise<void> {
172
+ await graphql(
173
+ `
174
+ mutation ($input: UpdatePullRequestReviewInput!) {
175
+ updatePullRequestReview(input: $input) {
176
+ pullRequestReview {
177
+ id
178
+ }
179
+ }
180
+ }
181
+ `,
182
+ { input: { pullRequestReviewId: reviewId, body } },
183
+ );
184
+ }
185
+
186
+ export async function addLineThread(
187
+ reviewId: string,
188
+ path: string,
189
+ line: number,
190
+ startLine: number | undefined,
191
+ body: string,
192
+ ): Promise<void> {
193
+ const input: Record<string, unknown> = {
194
+ pullRequestReviewId: reviewId,
195
+ path,
196
+ line,
197
+ side: "RIGHT",
198
+ body,
199
+ };
200
+ if (startLine !== undefined && startLine > 0 && startLine < line) {
201
+ input.startLine = startLine;
202
+ input.startSide = "RIGHT";
203
+ }
204
+ await graphql(
205
+ `
206
+ mutation ($input: AddPullRequestReviewThreadInput!) {
207
+ addPullRequestReviewThread(input: $input) {
208
+ thread {
209
+ id
210
+ }
211
+ }
212
+ }
213
+ `,
214
+ { input },
215
+ );
216
+ }
217
+
218
+ export async function addFileThread(reviewId: string, path: string, body: string): Promise<void> {
219
+ await graphql(
220
+ `
221
+ mutation ($input: AddPullRequestReviewThreadInput!) {
222
+ addPullRequestReviewThread(input: $input) {
223
+ thread {
224
+ id
225
+ }
226
+ }
227
+ }
228
+ `,
229
+ {
230
+ input: {
231
+ pullRequestReviewId: reviewId,
232
+ path,
233
+ body,
234
+ subjectType: "FILE",
235
+ },
236
+ },
237
+ );
238
+ }
239
+
240
+ export async function updateReviewComment(commentId: string, body: string): Promise<void> {
241
+ await graphql(
242
+ `
243
+ mutation ($input: UpdatePullRequestReviewCommentInput!) {
244
+ updatePullRequestReviewComment(input: $input) {
245
+ pullRequestReviewComment {
246
+ id
247
+ }
248
+ }
249
+ }
250
+ `,
251
+ { input: { pullRequestReviewCommentId: commentId, body } },
252
+ );
253
+ }
@@ -0,0 +1,65 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const FINDING_ID_RE = /<!-- finding:([a-f0-9]+) -->/u;
4
+
5
+ export function escapeHtml(s: string): string {
6
+ return s
7
+ .replaceAll("&", "&amp;")
8
+ .replaceAll("<", "&lt;")
9
+ .replaceAll(">", "&gt;")
10
+ .replaceAll('"', "&quot;")
11
+ .replaceAll("'", "&#39;");
12
+ }
13
+
14
+ export function isValidPath(filePath: string): boolean {
15
+ if (filePath.includes("\0")) return false;
16
+ if (filePath.startsWith("/") || filePath.includes("..")) return false;
17
+ if (filePath === "") return false;
18
+ // Normalize away harmless . and trailing slashes before comparison
19
+ const normalized = filePath
20
+ .split("/")
21
+ .filter((s) => s !== "." && s !== "")
22
+ .join("/");
23
+ return normalized.length > 0;
24
+ }
25
+
26
+ export interface RateLimitLike {
27
+ status?: number;
28
+ type?: string;
29
+ message?: string;
30
+ }
31
+
32
+ export function isRateLimitError(err: unknown): boolean {
33
+ if (err === null || typeof err !== "object") return false;
34
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Why: narrowed to object via typeof guard; property access checked individually below
35
+ const e = err as Record<string, unknown>;
36
+ const status = typeof e.status === "number" ? e.status : undefined;
37
+ const type = typeof e.type === "string" ? e.type : undefined;
38
+ const message = typeof e.message === "string" ? e.message.toLowerCase() : "";
39
+
40
+ if (status === 429) return true;
41
+ if (status === 403) {
42
+ return message.includes("rate limit") || message.includes("secondary rate limit");
43
+ }
44
+ if (type === "RATE_LIMITED") return true;
45
+ return message.includes("rate limit") || message.includes("secondary rate limit");
46
+ }
47
+
48
+ export function findingId(
49
+ path: string,
50
+ startLine: number | undefined,
51
+ line: number,
52
+ body = "",
53
+ ): string {
54
+ const key = `${path}:${startLine ?? 0}:${line}:${body.slice(0, 200)}`;
55
+ return crypto.createHash("sha256").update(key).digest("hex").slice(0, 12);
56
+ }
57
+
58
+ export function embedFindingId(body: string, id: string): string {
59
+ return `${body}\n<!-- finding:${id} -->`;
60
+ }
61
+
62
+ export function extractFindingId(body: string): string | null {
63
+ const match = FINDING_ID_RE.exec(body);
64
+ return match ? match[1] : null;
65
+ }