@mechanai/deepreview 0.0.0-development → 2.0.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/src/graphql.ts ADDED
@@ -0,0 +1,183 @@
1
+ import { type ExecFileOptions, execFile } from "node:child_process";
2
+ import { promisify } from "node:util";
3
+
4
+ // oxlint-disable-next-line typescript/strict-void-return -- Why: promisify() overload resolution picks void-returning signature incorrectly
5
+ export const execFileAsync = promisify(execFile);
6
+
7
+ /**
8
+ * Spawn a child process via execFile, writing `opts.input` to stdin.
9
+ * Resolves with stdout on success; rejects with an Error that has a `stderr` property on failure.
10
+ */
11
+ async function execFileWithInput(
12
+ cmd: string,
13
+ args: string[],
14
+ opts: ExecFileOptions & { input: string },
15
+ ): Promise<string> {
16
+ return new Promise((resolve, reject) => {
17
+ const child = execFile(cmd, args, opts, (error, stdout, stderr) => {
18
+ if (error) {
19
+ const e = error as Error & { stderr?: string };
20
+ e.stderr = typeof stderr === "string" ? stderr : "";
21
+ reject(e);
22
+ return;
23
+ }
24
+ const stderrStr = typeof stderr === "string" ? stderr : stderr.toString();
25
+ if (stderrStr.trim()) console.warn(`[gh stderr] ${stderrStr.trim()}`);
26
+ resolve(typeof stdout === "string" ? stdout : stdout.toString());
27
+ });
28
+ if (!child.stdin) {
29
+ child.kill();
30
+ reject(new Error("execFileWithInput: child process has no stdin"));
31
+ return;
32
+ }
33
+ child.stdin.on("error", (err: NodeJS.ErrnoException) => {
34
+ if (err.code !== "EPIPE") {
35
+ console.error(`[graphql] stdin error: ${err.message}`);
36
+ }
37
+ });
38
+ child.stdin.end(opts.input);
39
+ });
40
+ }
41
+ const TIMEOUT_MS = 30_000;
42
+
43
+ interface GraphQLError {
44
+ message: string;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ class GraphQLResponseError extends Error {
49
+ errors: GraphQLError[];
50
+ constructor(message: string, errors: GraphQLError[]) {
51
+ super(message);
52
+ this.errors = errors;
53
+ }
54
+ }
55
+
56
+ interface GraphQLResponse<T> {
57
+ data?: T;
58
+ errors?: GraphQLError[];
59
+ }
60
+
61
+ /**
62
+ * Execute a GraphQL query via `gh api graphql --input -`.
63
+ */
64
+ export async function graphql<T = unknown>(
65
+ query: string,
66
+ variables: Record<string, unknown> = {},
67
+ ): Promise<T> {
68
+ const body = JSON.stringify({ query, variables });
69
+ let result: string;
70
+ try {
71
+ result = await execFileWithInput("gh", ["api", "graphql", "--input", "-"], {
72
+ input: body,
73
+ encoding: "utf8",
74
+ maxBuffer: 10 * 1024 * 1024,
75
+ signal: AbortSignal.timeout(TIMEOUT_MS),
76
+ });
77
+ } catch (err: unknown) {
78
+ const stderr =
79
+ err !== null && typeof err === "object" && "stderr" in err
80
+ ? String((err as { stderr: unknown }).stderr).trim()
81
+ : "";
82
+ const message =
83
+ err !== null && typeof err === "object" && "message" in err
84
+ ? String((err as { message: unknown }).message)
85
+ : "unknown error";
86
+ const detail = stderr === "" ? message : stderr;
87
+ throw new Error(`gh graphql failed: ${detail}`);
88
+ }
89
+
90
+ let parsed: GraphQLResponse<T>;
91
+ try {
92
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Why: JSON.parse returns any; structural validation follows
93
+ parsed = JSON.parse(result) as GraphQLResponse<T>;
94
+ } catch {
95
+ throw new Error(`gh graphql returned non-JSON: ${result.slice(0, 200)}`);
96
+ }
97
+ if (parsed.errors && parsed.errors.length > 0) {
98
+ const msg = parsed.errors.map((e) => e.message).join("; ");
99
+ throw new GraphQLResponseError(`GraphQL error: ${msg}`, parsed.errors);
100
+ }
101
+ if (parsed.data === undefined || parsed.data === null) {
102
+ throw new Error("GraphQL response contained no data");
103
+ }
104
+ return parsed.data;
105
+ }
106
+
107
+ export interface PrInfo {
108
+ owner: string;
109
+ name: string;
110
+ prNodeId: string;
111
+ headOid: string;
112
+ state: string;
113
+ }
114
+
115
+ interface RepoViewResponse {
116
+ owner: { login: string };
117
+ name: string;
118
+ }
119
+
120
+ interface PrQueryResponse {
121
+ repository: {
122
+ pullRequest: {
123
+ id: string;
124
+ state: string;
125
+ headRefOid: string;
126
+ } | null;
127
+ };
128
+ }
129
+
130
+ /**
131
+ * Get repo owner, name, and PR details.
132
+ */
133
+ export async function getPrInfo(prNumber: number, { cwd }: { cwd?: string } = {}): Promise<PrInfo> {
134
+ let stdout: string;
135
+ try {
136
+ ({ stdout } = await execFileAsync("gh", ["repo", "view", "--json", "owner,name"], {
137
+ encoding: "utf8",
138
+ signal: AbortSignal.timeout(TIMEOUT_MS),
139
+ cwd,
140
+ }));
141
+ } catch (err: unknown) {
142
+ const message = err instanceof Error ? err.message : String(err);
143
+ throw new Error(
144
+ `Failed to query repository info via \`gh\`. Ensure \`gh auth login\` is complete.\n${message}`,
145
+ );
146
+ }
147
+ let repo: RepoViewResponse;
148
+ try {
149
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Why: JSON.parse returns any; parse failure caught below
150
+ repo = JSON.parse(stdout) as RepoViewResponse;
151
+ } catch {
152
+ throw new Error(
153
+ `Failed to parse \`gh repo view\` output as JSON. Got:\n${stdout.slice(0, 200)}`,
154
+ );
155
+ }
156
+
157
+ const data = await graphql<PrQueryResponse>(
158
+ `
159
+ query ($owner: String!, $name: String!, $number: Int!) {
160
+ repository(owner: $owner, name: $name) {
161
+ pullRequest(number: $number) {
162
+ id
163
+ state
164
+ headRefOid
165
+ }
166
+ }
167
+ }
168
+ `,
169
+ { owner: repo.owner.login, name: repo.name, number: prNumber },
170
+ );
171
+
172
+ const pr = data.repository.pullRequest;
173
+ if (!pr) {
174
+ throw new Error(`PR #${prNumber} not found in ${repo.owner.login}/${repo.name}`);
175
+ }
176
+ return {
177
+ owner: repo.owner.login,
178
+ name: repo.name,
179
+ prNodeId: pr.id,
180
+ headOid: pr.headRefOid,
181
+ state: pr.state,
182
+ };
183
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, it } from "bun:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseThreads } from "./parse-threads.ts";
4
+
5
+ describe("parseThreads", () => {
6
+ it("parses a single finding", () => {
7
+ const input = [
8
+ "---",
9
+ "path: pkg/server/handler.go",
10
+ "line: 48",
11
+ "---",
12
+ "The error is silently discarded.",
13
+ ].join("\n");
14
+
15
+ const result = parseThreads(input);
16
+ assert.equal(result.length, 1);
17
+ assert.equal(result[0].path, "pkg/server/handler.go");
18
+ assert.equal(result[0].line, 48);
19
+ assert.equal(result[0].startLine, undefined);
20
+ assert.equal(result[0].body.trim(), "The error is silently discarded.");
21
+ });
22
+
23
+ it("parses multiple findings separated by ---", () => {
24
+ const input = [
25
+ "---",
26
+ "path: a.go",
27
+ "startLine: 10",
28
+ "line: 15",
29
+ "---",
30
+ "Finding one.",
31
+ "---",
32
+ "path: b.go",
33
+ "line: 3",
34
+ "---",
35
+ "Finding two.",
36
+ ].join("\n");
37
+
38
+ const result = parseThreads(input);
39
+ assert.equal(result.length, 2);
40
+ assert.equal(result[0].path, "a.go");
41
+ assert.equal(result[0].startLine, 10);
42
+ assert.equal(result[0].line, 15);
43
+ assert.equal(result[1].path, "b.go");
44
+ assert.equal(result[1].line, 3);
45
+ });
46
+
47
+ it("ignores startLine: 0 (treats as single-line)", () => {
48
+ const input = ["---", "path: x.go", "startLine: 0", "line: 5", "---", "Body."].join("\n");
49
+
50
+ const result = parseThreads(input);
51
+ assert.equal(result[0].startLine, undefined);
52
+ });
53
+
54
+ it("returns empty array for empty input", () => {
55
+ const result = parseThreads("");
56
+ assert.equal(result.length, 0);
57
+ });
58
+ });
@@ -0,0 +1,117 @@
1
+ import matter from "gray-matter";
2
+ import yaml from "js-yaml";
3
+ import type { Finding } from "./diff-classifier.ts";
4
+
5
+ /**
6
+ * gray-matter engine restricted to safe YAML parsing (no !!js/function etc.).
7
+ * FAILSAFE_SCHEMA returns all scalars as strings — numeric fields (line, startLine)
8
+ * are coerced to number downstream in parseThreads. This is intentional.
9
+ */
10
+ const safeYamlEngine = (s: string): Record<string, unknown> => {
11
+ const result: unknown = yaml.load(s, { schema: yaml.FAILSAFE_SCHEMA });
12
+ if (result === null || result === undefined || typeof result !== "object") return {};
13
+ // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- Why: narrowed to object via typeof guard above; FAILSAFE_SCHEMA always yields a plain object
14
+ return result as Record<string, unknown>;
15
+ };
16
+ const matterOptions = { engines: { yaml: safeYamlEngine } };
17
+
18
+ /**
19
+ * Parse a threads.md file into an array of finding objects.
20
+ * The file uses --- separators between findings, each with YAML frontmatter.
21
+ */
22
+ function parseThreads(content: string): Finding[] {
23
+ const findings: Finding[] = [];
24
+ const documents = splitDocuments(content);
25
+
26
+ for (const doc of documents) {
27
+ let parsed: matter.GrayMatterFile<string>;
28
+ try {
29
+ parsed = matter(doc, matterOptions);
30
+ } catch (err: unknown) {
31
+ const message = err instanceof Error ? err.message : String(err);
32
+ console.warn(`Skipping malformed finding: ${message}`);
33
+ continue;
34
+ }
35
+ const data = parsed.data as Record<string, unknown>;
36
+ const path = typeof data.path === "string" ? data.path : undefined;
37
+ const lineNum = Number(data.line);
38
+ if (path === undefined || path === "" || !Number.isFinite(lineNum) || lineNum < 1) {
39
+ if (path !== undefined && path !== "") {
40
+ console.warn(`WARN: Skipping finding with invalid line number in ${path}`);
41
+ }
42
+ continue;
43
+ }
44
+
45
+ const rawStartLine: unknown = data.startLine;
46
+ const startLineNum =
47
+ rawStartLine !== null && rawStartLine !== undefined ? Number(rawStartLine) : undefined;
48
+ findings.push({
49
+ path,
50
+ line: lineNum,
51
+ startLine:
52
+ startLineNum !== undefined && startLineNum > 0 && Number.isFinite(startLineNum)
53
+ ? startLineNum
54
+ : undefined,
55
+ body: parsed.content.trim(),
56
+ });
57
+ }
58
+
59
+ return findings;
60
+ }
61
+
62
+ const YAML_KEY_RE = /^\w[\w\s]*:/u;
63
+
64
+ function peekIsYamlKey(lines: string[], startIndex: number): boolean {
65
+ for (let j = startIndex; j < lines.length; j++) {
66
+ if (lines[j].trim() === "") continue;
67
+ return YAML_KEY_RE.test(lines[j].trim());
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * Split a multi-document frontmatter file into individual documents.
74
+ * Each document starts with "---" (YAML open), has frontmatter, then "---" (YAML close),
75
+ * then body content until the next document or EOF.
76
+ */
77
+ function splitDocuments(content: string): string[] {
78
+ const lines = content.split("\n");
79
+ const documents: string[] = [];
80
+ let current: string[] = [];
81
+ let inFrontmatter = false;
82
+ let foundFirstDoc = false;
83
+
84
+ for (let i = 0; i < lines.length; i++) {
85
+ const line = lines[i];
86
+ if (line.trim() === "---") {
87
+ if (!foundFirstDoc) {
88
+ foundFirstDoc = true;
89
+ inFrontmatter = true;
90
+ current.push(line);
91
+ } else if (inFrontmatter) {
92
+ inFrontmatter = false;
93
+ current.push(line);
94
+ } else {
95
+ // Only split if this looks like a new document (next non-blank line has a YAML key)
96
+ const looksLikeNewDoc = peekIsYamlKey(lines, i + 1);
97
+ if (looksLikeNewDoc) {
98
+ documents.push(current.join("\n"));
99
+ current = [line];
100
+ inFrontmatter = true;
101
+ } else {
102
+ current.push(line);
103
+ }
104
+ }
105
+ } else if (foundFirstDoc) {
106
+ current.push(line);
107
+ }
108
+ }
109
+
110
+ if (current.length > 0) {
111
+ documents.push(current.join("\n"));
112
+ }
113
+
114
+ return documents;
115
+ }
116
+
117
+ export { parseThreads };
@@ -0,0 +1,52 @@
1
+ import { describe, it } from "bun:test";
2
+ import assert from "node:assert/strict";
3
+ import { parseThreads } from "./parse-threads.ts";
4
+ import { classifyFindings } from "./diff-classifier.ts";
5
+
6
+ describe("integration: parse → classify", () => {
7
+ const threadsContent = [
8
+ "---",
9
+ "path: src/main.go",
10
+ "startLine: 10",
11
+ "line: 15",
12
+ "---",
13
+ "Error not propagated.",
14
+ "---",
15
+ "path: src/main.go",
16
+ "line: 200",
17
+ "---",
18
+ "Missing docs.",
19
+ "---",
20
+ "path: pkg/other.go",
21
+ "line: 5",
22
+ "---",
23
+ "Unused import.",
24
+ ].join("\n");
25
+
26
+ const diff = [
27
+ "diff --git a/src/main.go b/src/main.go",
28
+ "index abc..def 100644",
29
+ "--- a/src/main.go",
30
+ "+++ b/src/main.go",
31
+ "@@ -8,6 +8,10 @@ func main() {",
32
+ " existing()",
33
+ "+ newCode()",
34
+ "+ moreNew()",
35
+ "+ evenMore()",
36
+ "+ andMore()",
37
+ " rest()",
38
+ ].join("\n");
39
+
40
+ it("classifies findings correctly across all 3 tiers", () => {
41
+ const findings = parseThreads(threadsContent);
42
+ assert.equal(findings.length, 3);
43
+
44
+ const classified = classifyFindings(findings, diff);
45
+ // line 10-15 is within hunk (newStart=8, newLines=10) → tier 1
46
+ assert.equal(classified[0].tier, 1);
47
+ // line 200 in src/main.go but not in hunk → tier 2
48
+ assert.equal(classified[1].tier, 2);
49
+ // pkg/other.go not in diff → tier 3
50
+ assert.equal(classified[2].tier, 3);
51
+ });
52
+ });