@krotovm/gitlab-ai-review 1.0.30 → 1.0.33

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/README.md CHANGED
@@ -55,6 +55,7 @@ Set these in your project/group CI settings:
55
55
  - `OPENAI_API_KEY` (required)
56
56
  - `OPENAI_BASE_URL` (optional, for OpenAI-compatible providers/proxies)
57
57
  - `AI_MODEL` (optional, default: `gpt-4o-mini`; example: `gpt-4o`)
58
+ - `AI_PROMPT_PROFILE` (optional, default: `default`; one of `default` \| `weak`). Use `weak` for small or heavily-quantized models (≤ ~7B, local Ollama, etc.). The `weak` profile uses shorter system prompts, positive rules instead of negations, and inline few-shot examples for triage / per-file review / consolidation / verification, which dramatically improves output-format adherence on weak models.
58
59
  - `PROJECT_ACCESS_TOKEN` (optional for public projects, but required for most private projects; token with `api` scope)
59
60
  - `GITLAB_TOKEN` (optional alias for `PROJECT_ACCESS_TOKEN`)
60
61
  - `AI_REVIEW_ARTIFACT_HTML_FILE` (optional, default: `ai-review-report.html`; used with `--include-artifacts`)
package/dist/cli/args.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** @format */
2
- import { DEFAULT_PROMPT_LIMITS, } from "../prompt/index.js";
2
+ import { DEFAULT_PROMPT_LIMITS, parsePromptProfile as parsePromptProfileValue, } from "../prompt/index.js";
3
3
  export function requireEnvs(names) {
4
4
  const missing = names.filter((name) => {
5
5
  const value = process.env[name];
@@ -91,3 +91,8 @@ export function hasIgnoredExtension(filePath, ignoredExtensions) {
91
91
  const lowerPath = filePath.toLowerCase();
92
92
  return ignoredExtensions.some((ext) => lowerPath.endsWith(ext));
93
93
  }
94
+ /** Reads AI_PROMPT_PROFILE (values: "default" | "weak"). Anything else falls
95
+ * back to the default profile. */
96
+ export function readPromptProfileFromEnv() {
97
+ return parsePromptProfileValue(process.env["AI_PROMPT_PROFILE"]);
98
+ }
@@ -1,6 +1,6 @@
1
1
  /** @format */
2
2
  import OpenAI from "openai";
3
- import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, extractCompletionText, parseTriageResponse, } from "../prompt/index.js";
3
+ import { buildAnswer, buildConsolidatePrompt, buildFileReviewPrompt, buildPrompt, buildTriagePrompt, buildVerificationPrompt, DEFAULT_PROMPT_PROFILE, extractCompletionText, isEmptyReviewBody, NO_FINDINGS_SENTENCE, parseTriageResponseDetailed, parseTriageResponse, } from "../prompt/index.js";
4
4
  import { fetchFileAtRef, searchRepository, } from "../gitlab/services.js";
5
5
  import { logToolUsageMinimal, MAX_FILE_TOOL_ROUNDS, MAX_TOOL_ROUNDS, MAX_VERIFICATION_TOOL_ROUNDS, TOOL_NAME_GET_FILE, TOOL_NAME_GREP, } from "./tooling.js";
6
6
  async function appendDebugDump(_debugDumpFile, debugRecordWriter, record) {
@@ -162,12 +162,14 @@ async function mapWithConcurrency(items, concurrency, fn) {
162
162
  return results;
163
163
  }
164
164
  export async function reviewMergeRequestWithTools(params) {
165
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
165
+ const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
166
166
  const { logDebug, logStep } = loggers;
167
+ logStep(`Single-pass review started for ${changes.length} file(s) (fallback mode).`);
167
168
  const messages = buildPrompt({
168
169
  changes: changes.map((change) => ({ diff: change.diff })),
169
170
  limits: promptLimits,
170
171
  allowTools: true,
172
+ profile: promptProfile,
171
173
  });
172
174
  messages.push({
173
175
  role: "user",
@@ -217,6 +219,7 @@ export async function reviewMergeRequestWithTools(params) {
217
219
  },
218
220
  ];
219
221
  for (let round = 0; round < MAX_TOOL_ROUNDS; round += 1) {
222
+ logStep(`Single-pass round ${round + 1}/${MAX_TOOL_ROUNDS}: requesting model response...`);
220
223
  const completion = await createCompletionWithDebug({
221
224
  openaiInstance,
222
225
  requestLabel: `main_review_round_${round + 1}`,
@@ -236,6 +239,7 @@ export async function reviewMergeRequestWithTools(params) {
236
239
  return buildAnswer(completion);
237
240
  const toolCalls = message.tool_calls ?? [];
238
241
  logDebug(`main-review round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
242
+ logStep(`Single-pass round ${round + 1}: model returned ${toolCalls.length} tool call(s).`);
239
243
  if (toolCalls.length === 0)
240
244
  return buildAnswer(completion);
241
245
  messages.push({
@@ -290,6 +294,7 @@ export async function reviewMergeRequestWithTools(params) {
290
294
  role: "user",
291
295
  content: `Tool-call limit reached (${MAX_TOOL_ROUNDS}). Do not call any tools. Provide your best-effort final review now, strictly following the required output format. If confidence is low, return the exact no-issues sentence.`,
292
296
  });
297
+ logStep("Single-pass tool-call limit reached. Requesting final answer.");
293
298
  const finalCompletion = await createCompletionWithDebug({
294
299
  openaiInstance,
295
300
  requestLabel: "main_review_final_after_tool_limit",
@@ -305,7 +310,7 @@ export async function reviewMergeRequestWithTools(params) {
305
310
  return buildAnswer(finalCompletion);
306
311
  }
307
312
  async function runFileReviewWithTools(params) {
308
- const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
313
+ const { openaiInstance, aiModel, filePath, fileDiff, summary, otherChangedFiles, refs, gitLabProjectApiUrl, projectId, headers, forceTools, promptProfile, loggers, debugDumpFile, debugRecordWriter, } = params;
309
314
  const { logDebug, logStep } = loggers;
310
315
  const messages = buildFileReviewPrompt({
311
316
  filePath,
@@ -313,6 +318,7 @@ async function runFileReviewWithTools(params) {
313
318
  summary,
314
319
  otherChangedFiles,
315
320
  allowTools: true,
321
+ profile: promptProfile,
316
322
  });
317
323
  const tools = [
318
324
  {
@@ -374,11 +380,11 @@ async function runFileReviewWithTools(params) {
374
380
  });
375
381
  const msg = completion.choices[0]?.message;
376
382
  if (msg == null)
377
- return extractCompletionText(completion) ?? "No issues found.";
383
+ return extractCompletionText(completion) ?? NO_FINDINGS_SENTENCE;
378
384
  const toolCalls = msg.tool_calls ?? [];
379
385
  logDebug(`file-review path=${filePath} round=${round + 1} tool_calls=${toolCalls.length} finish_reason=${completion.choices[0]?.finish_reason ?? "unknown"}`);
380
386
  if (toolCalls.length === 0)
381
- return extractCompletionText(completion) ?? "No issues found.";
387
+ return extractCompletionText(completion) ?? NO_FINDINGS_SENTENCE;
382
388
  messages.push({
383
389
  role: "assistant",
384
390
  content: msg.content ?? "",
@@ -445,7 +451,7 @@ async function runFileReviewWithTools(params) {
445
451
  messages,
446
452
  },
447
453
  });
448
- return extractCompletionText(final) ?? "No issues found.";
454
+ return extractCompletionText(final) ?? NO_FINDINGS_SENTENCE;
449
455
  }
450
456
  function draftHasStructuredFindings(consolidatedText) {
451
457
  return /-\s*\[(?:high|medium)\]/i.test(consolidatedText);
@@ -586,9 +592,9 @@ async function runVerificationWithTools(params) {
586
592
  });
587
593
  }
588
594
  export async function reviewMergeRequestMultiPass(params) {
589
- const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, loggers, debugDumpFile, debugRecordWriter, } = params;
595
+ const { openaiInstance, aiModel, promptLimits, changes, refs, gitLabProjectApiUrl, projectId, headers, maxFindings, reviewConcurrency, forceTools, promptProfile = DEFAULT_PROMPT_PROFILE, loggers, debugDumpFile, debugRecordWriter, } = params;
590
596
  const { logStep } = loggers;
591
- logStep(`Pass 1/4: triaging ${changes.length} file(s)`);
597
+ logStep(`Pass 1/4: triaging ${changes.length} file(s) (prompt profile=${promptProfile})`);
592
598
  const triageInputs = changes.map((c) => ({
593
599
  path: c.new_path,
594
600
  new_file: c.new_file,
@@ -596,9 +602,11 @@ export async function reviewMergeRequestMultiPass(params) {
596
602
  renamed_file: c.renamed_file,
597
603
  diff: c.diff,
598
604
  }));
599
- const triageMessages = buildTriagePrompt(triageInputs);
605
+ const triageMessages = buildTriagePrompt(triageInputs, promptProfile);
600
606
  let triageResult = null;
601
607
  let triageText = null;
608
+ let triageParseReason = null;
609
+ let triageParseError = null;
602
610
  try {
603
611
  const triageCompletion = await createCompletionWithDebug({
604
612
  openaiInstance,
@@ -614,8 +622,12 @@ export async function reviewMergeRequestMultiPass(params) {
614
622
  },
615
623
  });
616
624
  triageText = extractCompletionText(triageCompletion);
617
- if (triageText != null)
618
- triageResult = parseTriageResponse(triageText);
625
+ if (triageText != null) {
626
+ const triageParse = parseTriageResponseDetailed(triageText);
627
+ triageResult = triageParse.result;
628
+ triageParseReason = triageParse.reason;
629
+ triageParseError = triageParse.parseError;
630
+ }
619
631
  }
620
632
  catch (error) {
621
633
  logStep(`Triage pass failed: ${error?.message ?? error}. Falling back to single-pass.`);
@@ -624,7 +636,11 @@ export async function reviewMergeRequestMultiPass(params) {
624
636
  if (triageText != null) {
625
637
  const triagePreview = triageText.replace(/\s+/g, " ").trim().slice(0, 200);
626
638
  const looksLikeHtml = /<html|<!doctype html/i.test(triageText);
627
- logStep(`Triage parse failed: expected JSON but got ${looksLikeHtml ? "HTML/non-JSON" : "non-JSON"} response. Preview: ${triagePreview || "<empty>"}`);
639
+ const parseReasonText = triageParseReason != null
640
+ ? `reason=${triageParseReason}`
641
+ : `reason=${looksLikeHtml ? "html_response" : "unknown_non_json"}`;
642
+ const parseErrorText = triageParseError != null ? ` parse_error=${triageParseError}` : "";
643
+ logStep(`Triage parse failed: ${parseReasonText}.${parseErrorText} Preview: ${triagePreview || "<empty>"}`);
628
644
  }
629
645
  else {
630
646
  logStep("Triage parse failed: model returned empty response body.");
@@ -640,6 +656,7 @@ export async function reviewMergeRequestMultiPass(params) {
640
656
  projectId,
641
657
  headers,
642
658
  forceTools,
659
+ promptProfile,
643
660
  loggers,
644
661
  debugDumpFile,
645
662
  debugRecordWriter,
@@ -671,6 +688,7 @@ export async function reviewMergeRequestMultiPass(params) {
671
688
  projectId,
672
689
  headers,
673
690
  forceTools,
691
+ promptProfile,
674
692
  loggers,
675
693
  debugDumpFile,
676
694
  debugRecordWriter,
@@ -682,10 +700,11 @@ export async function reviewMergeRequestMultiPass(params) {
682
700
  perFileFindings,
683
701
  summary: triageResult.summary,
684
702
  maxFindings,
703
+ profile: promptProfile,
685
704
  });
686
705
  if (consolidateMessages == null) {
687
706
  const DISCLAIMER = "This comment was generated by AI review bot.";
688
- return `No confirmed bugs or high-value optimizations found.\n\n---\n_${DISCLAIMER}_`;
707
+ return `${NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
689
708
  }
690
709
  try {
691
710
  const consolidateCompletion = await createCompletionWithDebug({
@@ -711,6 +730,7 @@ export async function reviewMergeRequestMultiPass(params) {
711
730
  consolidatedFindings: consolidatedText,
712
731
  maxFindings,
713
732
  refs,
733
+ profile: promptProfile,
714
734
  });
715
735
  try {
716
736
  const verificationCompletion = await runVerificationWithTools({
@@ -738,10 +758,9 @@ export async function reviewMergeRequestMultiPass(params) {
738
758
  logStep(`Consolidation failed: ${error?.message ?? error}. Returning raw per-file findings.`);
739
759
  const DISCLAIMER = "This comment was generated by AI review bot.";
740
760
  const raw = perFileFindings
741
- .filter((f) => !f.findings.includes("No issues found.") &&
742
- !f.findings.includes("No confirmed bugs"))
761
+ .filter((f) => !isEmptyReviewBody(f.findings))
743
762
  .map((f) => f.findings)
744
763
  .join("\n");
745
- return `${raw || "No confirmed bugs or high-value optimizations found."}\n\n---\n_${DISCLAIMER}_`;
764
+ return `${raw || NO_FINDINGS_SENTENCE}\n\n---\n_${DISCLAIMER}_`;
746
765
  }
747
766
  }
package/dist/cli.js CHANGED
@@ -3,7 +3,7 @@
3
3
  import OpenAI from "openai";
4
4
  import { readFile } from "node:fs/promises";
5
5
  import { DEFAULT_MAX_FINDINGS, DEFAULT_REVIEW_CONCURRENCY, } from "./prompt/index.js";
6
- import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, requireEnvs, } from "./cli/args.js";
6
+ import { envOrDefault, envOrUndefined, hasDebugFlag, hasForceToolsFlag, hasIncludeArtifactsFlag, hasIgnoredExtension, parseIgnoreExtensions, parseNumberFlag, parsePromptLimits, readPromptProfileFromEnv, requireEnvs, } from "./cli/args.js";
7
7
  import { reviewMergeRequestMultiPass } from "./cli/ci-review.js";
8
8
  import { fetchMergeRequestChanges, postMergeRequestNote, } from "./gitlab/services.js";
9
9
  import { renderDebugArtifactsHtml } from "./cli/debug-artifacts-html.js";
@@ -32,6 +32,9 @@ function printHelp() {
32
32
  " OPENAI_API_KEY (required) OpenAI API key.",
33
33
  " OPENAI_BASE_URL (optional) Custom OpenAI-compatible API base URL.",
34
34
  " AI_MODEL (optional) OpenAI chat model, e.g. gpt-4o. Default: gpt-4o-mini.",
35
+ " AI_PROMPT_PROFILE (optional) Prompt style: \"default\" | \"weak\". Default: default.",
36
+ " Use \"weak\" for small/quantized models — shorter prompts,",
37
+ " positive rules, and few-shot examples.",
35
38
  " PROJECT_ACCESS_TOKEN (optional) GitLab Project/Personal Access Token for API calls (required for most private repos; should have api scope).",
36
39
  "",
37
40
  "CI-only env vars (provided by GitLab):",
@@ -77,6 +80,8 @@ async function main() {
77
80
  const maxFindings = parseNumberFlag(process.argv, "max-findings", DEFAULT_MAX_FINDINGS, 1);
78
81
  const reviewConcurrency = parseNumberFlag(process.argv, "max-review-concurrency", DEFAULT_REVIEW_CONCURRENCY, 1);
79
82
  const aiModel = envOrDefault("AI_MODEL", "gpt-4o-mini");
83
+ const promptProfile = readPromptProfileFromEnv();
84
+ logStep(`Prompt profile: ${promptProfile}`);
80
85
  const artifactHtmlFile = INCLUDE_ARTIFACTS
81
86
  ? envOrDefault("AI_REVIEW_ARTIFACT_HTML_FILE", "ai-review-report.html")
82
87
  : undefined;
@@ -86,6 +91,7 @@ async function main() {
86
91
  artifactRecords.push(record);
87
92
  }
88
93
  : undefined;
94
+ let artifactWritten = false;
89
95
  const loggers = { logStep, logDebug };
90
96
  const projectAccessToken = envOrUndefined("PROJECT_ACCESS_TOKEN") ?? envOrUndefined("GITLAB_TOKEN");
91
97
  const gitlabRequired = [
@@ -106,58 +112,72 @@ async function main() {
106
112
  headers["PRIVATE-TOKEN"] = projectAccessToken;
107
113
  else
108
114
  headers["JOB-TOKEN"] = envs["CI_JOB_TOKEN"];
109
- logStep("Fetching merge request changes");
110
- const mrChanges = await fetchMergeRequestChanges({
111
- gitLabBaseUrl: new URL(ciApiV4Url),
112
- headers,
113
- projectId,
114
- mergeRequestIid,
115
- });
116
- if (mrChanges instanceof Error)
117
- throw mrChanges;
118
- const filteredChanges = (mrChanges.changes ?? []).filter((change) => ignoredExtensions.length === 0 ||
119
- (!hasIgnoredExtension(change.new_path, ignoredExtensions) &&
120
- !hasIgnoredExtension(change.old_path, ignoredExtensions)));
121
- if (filteredChanges.length === 0) {
122
- process.stdout.write("No changes found in merge request. Skipping review.\n");
123
- return;
124
- }
125
- logStep(`Requesting AI review with model: ${aiModel} (multi-pass pipeline)`);
126
- const answer = await reviewMergeRequestMultiPass({
127
- openaiInstance: new OpenAI({ apiKey: openaiApiKey }),
128
- aiModel,
129
- promptLimits,
130
- changes: filteredChanges,
131
- refs: {
132
- base: mrChanges.diff_refs?.base_sha ?? "HEAD",
133
- head: mrChanges.diff_refs?.head_sha ?? "HEAD",
134
- },
135
- gitLabProjectApiUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
136
- projectId,
137
- headers,
138
- maxFindings,
139
- reviewConcurrency,
140
- forceTools: FORCE_TOOLS,
141
- loggers,
142
- debugRecordWriter,
143
- });
144
- logStep("Posting AI review note to merge request");
145
- const noteRes = await postMergeRequestNote({
146
- gitLabBaseUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
147
- headers,
148
- mergeRequestIid,
149
- }, { body: answer });
150
- if (noteRes instanceof Error)
151
- throw noteRes;
152
- if (INCLUDE_ARTIFACTS && artifactHtmlFile != null) {
153
- await renderDebugArtifactsHtml({
154
- records: artifactRecords,
155
- artifactHtmlFile,
156
- cliVersion,
115
+ try {
116
+ logStep("Fetching merge request changes");
117
+ const mrChanges = await fetchMergeRequestChanges({
118
+ gitLabBaseUrl: new URL(ciApiV4Url),
119
+ headers,
120
+ projectId,
121
+ mergeRequestIid,
122
+ });
123
+ if (mrChanges instanceof Error)
124
+ throw mrChanges;
125
+ const filteredChanges = (mrChanges.changes ?? []).filter((change) => ignoredExtensions.length === 0 ||
126
+ (!hasIgnoredExtension(change.new_path, ignoredExtensions) &&
127
+ !hasIgnoredExtension(change.old_path, ignoredExtensions)));
128
+ if (filteredChanges.length === 0) {
129
+ process.stdout.write("No changes found in merge request. Skipping review.\n");
130
+ return;
131
+ }
132
+ logStep(`Requesting AI review with model: ${aiModel} (multi-pass pipeline)`);
133
+ const answer = await reviewMergeRequestMultiPass({
134
+ openaiInstance: new OpenAI({ apiKey: openaiApiKey }),
157
135
  aiModel,
136
+ promptLimits,
137
+ changes: filteredChanges,
138
+ refs: {
139
+ base: mrChanges.diff_refs?.base_sha ?? "HEAD",
140
+ head: mrChanges.diff_refs?.head_sha ?? "HEAD",
141
+ },
142
+ gitLabProjectApiUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
143
+ projectId,
144
+ headers,
145
+ maxFindings,
146
+ reviewConcurrency,
147
+ forceTools: FORCE_TOOLS,
148
+ promptProfile,
149
+ loggers,
150
+ debugRecordWriter,
158
151
  });
152
+ logStep("Posting AI review note to merge request");
153
+ const noteRes = await postMergeRequestNote({
154
+ gitLabBaseUrl: new URL(`${ciApiV4Url}/projects/${projectId}`),
155
+ headers,
156
+ mergeRequestIid,
157
+ }, { body: answer });
158
+ if (noteRes instanceof Error)
159
+ throw noteRes;
160
+ process.stdout.write("Posted AI review comment to merge request.\n");
161
+ }
162
+ finally {
163
+ if (INCLUDE_ARTIFACTS && artifactHtmlFile != null && !artifactWritten) {
164
+ try {
165
+ await renderDebugArtifactsHtml({
166
+ records: artifactRecords,
167
+ artifactHtmlFile,
168
+ cliVersion,
169
+ aiModel,
170
+ });
171
+ artifactWritten = true;
172
+ }
173
+ catch (artifactError) {
174
+ const message = artifactError instanceof Error
175
+ ? artifactError.message
176
+ : String(artifactError);
177
+ process.stderr.write(`Failed to write debug artifact "${artifactHtmlFile}": ${message}\n`);
178
+ }
179
+ }
159
180
  }
160
- process.stdout.write("Posted AI review comment to merge request.\n");
161
181
  }
162
182
  main().catch((err) => {
163
183
  const message = err instanceof Error ? err.message : String(err);
@@ -1,17 +1,18 @@
1
1
  /** @format */
2
- import { buildMainSystemMessages, FILE_REVIEW_SYSTEM, TRIAGE_SYSTEM, } from "./messages.js";
3
- import { normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
4
- import { buildConsolidateSystemLines, buildVerificationSystemLines, } from "./templates/postprocess-system.js";
2
+ import { buildFileReviewSystemMessage, buildMainSystemMessages, buildTriageSystemMessage, } from "./messages.js";
3
+ import { extractFirstJsonObject, normalizeReviewFindingsMarkdown, sanitizeGitLabMarkdown, truncateWithMarker, } from "./utils.js";
4
+ import { getConsolidateSystemLines, getVerificationSystemLines, } from "./templates/postprocess-system.js";
5
5
  import { buildConsolidateUserContent, buildFileReviewUserContent, buildMainReviewUserContent, buildTriageUserContent, buildVerificationUserContent, } from "./templates/user-prompts.js";
6
+ import { DEFAULT_PROMPT_PROFILE, isEmptyReviewBody } from "./profile.js";
7
+ export { DEFAULT_PROMPT_PROFILE, NO_FINDINGS_SENTENCE, isEmptyReviewBody, parsePromptProfile, } from "./profile.js";
6
8
  export const DEFAULT_PROMPT_LIMITS = {
7
9
  maxDiffs: 50,
8
10
  maxDiffChars: 16000,
9
11
  maxTotalPromptChars: 220000,
10
12
  };
11
- const MESSAGES = buildMainSystemMessages();
12
13
  export const AI_MODEL_TEMPERATURE = 0.2;
13
14
  export const AI_MAX_OUTPUT_TOKENS = 600;
14
- export const buildPrompt = ({ changes, limits, allowTools = false, }) => {
15
+ export const buildPrompt = ({ changes, limits, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, }) => {
15
16
  const effectiveLimits = {
16
17
  ...DEFAULT_PROMPT_LIMITS,
17
18
  ...(limits ?? {}),
@@ -41,16 +42,19 @@ export const buildPrompt = ({ changes, limits, allowTools = false, }) => {
41
42
  changesText,
42
43
  });
43
44
  const boundedContent = truncateWithMarker(userContent, effectiveLimits.maxTotalPromptChars, "prompt payload");
44
- return [...MESSAGES, { role: "user", content: boundedContent }];
45
+ return [
46
+ ...buildMainSystemMessages(profile),
47
+ { role: "user", content: boundedContent },
48
+ ];
45
49
  };
46
50
  // ---------------------------------------------------------------------------
47
51
  // Multi-pass pipeline prompts
48
52
  // ---------------------------------------------------------------------------
49
53
  export const DEFAULT_MAX_FINDINGS = 5;
50
54
  export const DEFAULT_REVIEW_CONCURRENCY = 5;
51
- export function buildTriagePrompt(changes) {
55
+ export function buildTriagePrompt(changes, profile = DEFAULT_PROMPT_PROFILE) {
52
56
  return [
53
- TRIAGE_SYSTEM,
57
+ buildTriageSystemMessage(profile),
54
58
  {
55
59
  role: "user",
56
60
  content: buildTriageUserContent(changes),
@@ -58,37 +62,74 @@ export function buildTriagePrompt(changes) {
58
62
  ];
59
63
  }
60
64
  export function parseTriageResponse(text) {
61
- try {
62
- const cleaned = text
63
- .replace(/```json?\s*/g, "")
64
- .replace(/```\s*/g, "")
65
- .trim();
66
- const parsed = JSON.parse(cleaned);
67
- if (typeof parsed.summary === "string" && Array.isArray(parsed.files)) {
68
- return {
65
+ return parseTriageResponseDetailed(text).result;
66
+ }
67
+ export function parseTriageResponseDetailed(text) {
68
+ const cleaned = text
69
+ .replace(/```json?\s*/g, "")
70
+ .replace(/```\s*/g, "")
71
+ .trim();
72
+ if (cleaned === "") {
73
+ return { result: null, reason: "empty_response", parseError: null };
74
+ }
75
+ const tryParse = (src) => {
76
+ try {
77
+ return { parsed: JSON.parse(src), error: null };
78
+ }
79
+ catch (e) {
80
+ const msg = e instanceof Error
81
+ ? e.message
82
+ : typeof e === "string"
83
+ ? e
84
+ : JSON.stringify(e);
85
+ return { parsed: null, error: msg };
86
+ }
87
+ };
88
+ let attempt = tryParse(cleaned);
89
+ // Some providers ignore JSON-mode and prefix prose like "Here is the JSON: {...}".
90
+ // Recover by extracting the first balanced {...} block and parsing that.
91
+ if (attempt.parsed == null) {
92
+ const extracted = extractFirstJsonObject(cleaned);
93
+ if (extracted != null && extracted !== cleaned) {
94
+ attempt = tryParse(extracted);
95
+ }
96
+ }
97
+ if (attempt.parsed == null || typeof attempt.parsed !== "object") {
98
+ return {
99
+ result: null,
100
+ reason: "invalid_json",
101
+ parseError: attempt.error,
102
+ };
103
+ }
104
+ const parsed = attempt.parsed;
105
+ if (typeof parsed.summary === "string" && Array.isArray(parsed.files)) {
106
+ return {
107
+ result: {
69
108
  summary: parsed.summary,
70
109
  files: parsed.files
71
- .filter((f) => typeof f.path === "string" &&
72
- (f.verdict === "NEEDS_REVIEW" || f.verdict === "SKIP"))
110
+ .filter((f) => typeof f === "object" &&
111
+ f != null &&
112
+ typeof f.path === "string" &&
113
+ (f.verdict === "NEEDS_REVIEW" ||
114
+ f.verdict === "SKIP"))
73
115
  .map((f) => ({
74
116
  path: f.path,
75
117
  verdict: f.verdict,
76
118
  })),
77
- };
78
- }
79
- }
80
- catch {
81
- // JSON parse failure — caller will fall back to single-pass.
119
+ },
120
+ reason: null,
121
+ parseError: null,
122
+ };
82
123
  }
83
- return null;
124
+ return { result: null, reason: "invalid_schema", parseError: null };
84
125
  }
85
126
  export function buildFileReviewPrompt(params) {
86
- const { filePath, fileDiff, summary, otherChangedFiles, allowTools = false, } = params;
127
+ const { filePath, fileDiff, summary, otherChangedFiles, allowTools = false, profile = DEFAULT_PROMPT_PROFILE, } = params;
87
128
  const toolNote = allowTools
88
129
  ? "Tools (get_file_at_ref, grep_repository) are available to verify suspicions or read the full file."
89
130
  : "Tools are unavailable; rely only on visible diff evidence.";
90
131
  return [
91
- FILE_REVIEW_SYSTEM,
132
+ buildFileReviewSystemMessage(profile),
92
133
  {
93
134
  role: "user",
94
135
  content: buildFileReviewUserContent({
@@ -102,9 +143,8 @@ export function buildFileReviewPrompt(params) {
102
143
  ];
103
144
  }
104
145
  export function buildConsolidatePrompt(params) {
105
- const { perFileFindings, summary, maxFindings } = params;
106
- const meaningful = perFileFindings.filter((f) => !f.findings.includes("No issues found.") &&
107
- !f.findings.includes("No confirmed bugs"));
146
+ const { perFileFindings, summary, maxFindings, profile = DEFAULT_PROMPT_PROFILE, } = params;
147
+ const meaningful = perFileFindings.filter((f) => !isEmptyReviewBody(f.findings));
108
148
  if (meaningful.length === 0)
109
149
  return null;
110
150
  const findingsText = meaningful
@@ -113,7 +153,7 @@ export function buildConsolidatePrompt(params) {
113
153
  return [
114
154
  {
115
155
  role: "system",
116
- content: buildConsolidateSystemLines(maxFindings).join("\n"),
156
+ content: getConsolidateSystemLines(profile, maxFindings).join("\n"),
117
157
  },
118
158
  {
119
159
  role: "user",
@@ -126,14 +166,14 @@ export function buildConsolidatePrompt(params) {
126
166
  ];
127
167
  }
128
168
  export function buildVerificationPrompt(params) {
129
- const { perFileFindings, summary, consolidatedFindings, maxFindings, refs } = params;
169
+ const { perFileFindings, summary, consolidatedFindings, maxFindings, refs, profile = DEFAULT_PROMPT_PROFILE, } = params;
130
170
  const findingsText = perFileFindings
131
171
  .map((f) => `### ${f.path}\n${f.findings}`)
132
172
  .join("\n\n");
133
173
  return [
134
174
  {
135
175
  role: "system",
136
- content: buildVerificationSystemLines(maxFindings).join("\n"),
176
+ content: getVerificationSystemLines(profile, maxFindings).join("\n"),
137
177
  },
138
178
  {
139
179
  role: "user",
@@ -1,17 +1,22 @@
1
1
  /** @format */
2
- import { FILE_REVIEW_SYSTEM_LINES, MAIN_SYSTEM_LINES, } from "./templates/review-system.js";
3
- import { TRIAGE_SYSTEM_LINES } from "./templates/triage-system.js";
4
- export const buildMainSystemMessages = () => [
2
+ import { getFileReviewSystemLines, getMainSystemLines, } from "./templates/review-system.js";
3
+ import { getTriageSystemLines } from "./templates/triage-system.js";
4
+ import { DEFAULT_PROMPT_PROFILE } from "./profile.js";
5
+ export const buildMainSystemMessages = (profile = DEFAULT_PROMPT_PROFILE) => [
5
6
  {
6
7
  role: "system",
7
- content: MAIN_SYSTEM_LINES.join("\n"),
8
+ content: getMainSystemLines(profile).join("\n"),
8
9
  },
9
10
  ];
10
- export const TRIAGE_SYSTEM = {
11
+ export const buildTriageSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
11
12
  role: "system",
12
- content: TRIAGE_SYSTEM_LINES.join("\n"),
13
- };
14
- export const FILE_REVIEW_SYSTEM = {
13
+ content: getTriageSystemLines(profile).join("\n"),
14
+ });
15
+ export const buildFileReviewSystemMessage = (profile = DEFAULT_PROMPT_PROFILE) => ({
15
16
  role: "system",
16
- content: FILE_REVIEW_SYSTEM_LINES.join("\n"),
17
- };
17
+ content: getFileReviewSystemLines(profile).join("\n"),
18
+ });
19
+ /** @deprecated kept for backward compatibility — use buildTriageSystemMessage(). */
20
+ export const TRIAGE_SYSTEM = buildTriageSystemMessage();
21
+ /** @deprecated kept for backward compatibility — use buildFileReviewSystemMessage(). */
22
+ export const FILE_REVIEW_SYSTEM = buildFileReviewSystemMessage();
@@ -0,0 +1,29 @@
1
+ /** @format */
2
+ export const DEFAULT_PROMPT_PROFILE = "default";
3
+ /** Canonical phrase the model is asked to emit when no findings survive
4
+ * review/verification. Parsers are tolerant of common synonyms. */
5
+ export const NO_FINDINGS_SENTENCE = "No confirmed bugs or high-value optimizations found.";
6
+ const NO_FINDINGS_PATTERNS = [
7
+ /\bno\s+(?:confirmed\s+)?(?:bugs?|issues?|findings?|defects?|problems?)\b[^.\n]{0,80}\b(?:found|detected|identified|reported)\b/i,
8
+ /\bnothing\s+to\s+report\b/i,
9
+ /\bno\s+high[- ]value\s+optimizations?\s+found\b/i,
10
+ /\ball\s+clear\b/i,
11
+ ];
12
+ const SEVERITY_HEADER_PATTERN = /\[(?:high|medium)\]/i;
13
+ /** Returns true when the model emitted a "no findings" sentinel (or a common
14
+ * synonym) and there are no severity bullets in the body. Severity bullets
15
+ * always win — a real finding is never treated as empty. */
16
+ export function isEmptyReviewBody(text) {
17
+ const trimmed = text.trim();
18
+ if (trimmed === "")
19
+ return true;
20
+ if (SEVERITY_HEADER_PATTERN.test(trimmed))
21
+ return false;
22
+ return NO_FINDINGS_PATTERNS.some((re) => re.test(trimmed));
23
+ }
24
+ export function parsePromptProfile(value) {
25
+ const v = (value ?? "").trim().toLowerCase();
26
+ if (v === "weak")
27
+ return "weak";
28
+ return "default";
29
+ }
@@ -1,5 +1,6 @@
1
1
  /** @format */
2
- export function buildConsolidateSystemLines(maxFindings) {
2
+ import { NO_FINDINGS_SENTENCE } from "../profile.js";
3
+ function buildConsolidateDefaultLines(maxFindings) {
3
4
  return [
4
5
  "You consolidate per-file code review findings into a final ranked list.",
5
6
  `Select the top ${maxFindings} most important findings. Deduplicate overlapping issues.`,
@@ -12,11 +13,43 @@ export function buildConsolidateSystemLines(maxFindings) {
12
13
  "Do not add new findings. Do not add headings, summaries, or commentary.",
13
14
  "Drop any finding that: claims a syntax error without quoting the invalid token, claims a function/variable is missing without concrete proof, or flags a refactoring rename as a bug.",
14
15
  `If fewer than ${maxFindings} findings exist, return all of them.`,
15
- 'If all findings are low quality or dubious after dedup, return exactly: "No confirmed bugs or high-value optimizations found."',
16
+ `If all findings are low quality or dubious after dedup, return exactly: "${NO_FINDINGS_SENTENCE}"`,
16
17
  "GitLab-flavoured markdown.",
17
18
  ];
18
19
  }
19
- export function buildVerificationSystemLines(maxFindings) {
20
+ function buildConsolidateWeakLines(maxFindings) {
21
+ return [
22
+ "You merge per-file review findings into one ranked list.",
23
+ "Output bullets only — no preamble, no headings, no code fences.",
24
+ `Keep at most ${maxFindings} findings. Do not invent new ones.`,
25
+ "",
26
+ "Steps:",
27
+ "1. Drop duplicates and findings that are reworded versions of each other (keep the clearest).",
28
+ "2. Drop a finding if it has no concrete evidence (no file/line, vague reasoning, or just \"this might be wrong\").",
29
+ "3. Sort the remainder: all [high] before any [medium]; within a tier, correctness bugs before security before perf.",
30
+ "4. Take the top items up to the limit.",
31
+ "",
32
+ "Output format — copy spacing and case exactly, do not wrap bullets in backticks:",
33
+ "- [high|medium] <short title>",
34
+ " File: <path>",
35
+ " Line: ~<N>",
36
+ " Why: <one sentence pointing at the evidence>",
37
+ "",
38
+ "Example output (two findings, ranked):",
39
+ "- [high] Null pointer dereference in user lookup",
40
+ " File: src/user/service.ts",
41
+ " Line: ~42",
42
+ " Why: getUser() can return null but the next line calls user.id without a guard.",
43
+ "",
44
+ "- [medium] Off-by-one in pagination loop",
45
+ " File: src/api/list.ts",
46
+ " Line: ~88",
47
+ " Why: loop runs while i <= total but indices are 0-based, so the last item is read past the array end.",
48
+ "",
49
+ `If after dedup nothing survives, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
50
+ ];
51
+ }
52
+ function buildVerificationDefaultLines(maxFindings) {
20
53
  return [
21
54
  "You are a skeptical verifier of a merge request review.",
22
55
  "Your job is to remove weak, speculative, or unsupported findings from the draft list.",
@@ -32,7 +65,54 @@ export function buildVerificationSystemLines(maxFindings) {
32
65
  "` Why: <one concise sentence with key evidence>`",
33
66
  "Do not add headings, summaries, or extra commentary.",
34
67
  `Return at most ${maxFindings} findings.`,
35
- 'If no findings survive verification, return exactly: "No confirmed bugs or high-value optimizations found."',
68
+ `If no findings survive verification, return exactly: "${NO_FINDINGS_SENTENCE}"`,
36
69
  "GitLab-flavoured markdown.",
37
70
  ];
38
71
  }
72
+ function buildVerificationWeakLines(maxFindings) {
73
+ return [
74
+ "You verify a draft list of merge-request review findings against the actual repository.",
75
+ "Output bullets only — no preamble, no headings, no code fences. Do not invent new findings.",
76
+ `Return at most ${maxFindings} findings.`,
77
+ "",
78
+ "For each draft finding, follow these steps in order:",
79
+ "1. Look it up in the per-file evidence pool. If it is not in the evidence pool, drop it.",
80
+ "2. If the finding makes a claim about code that exists at refs.head, call get_file_at_ref(path, refs.head) and confirm the offending text really sits at the cited line (±10 lines is fine).",
81
+ "3. If the file contents at refs.head do not contain the offending text, or contain a corrected version of it, drop the finding.",
82
+ "4. If you cannot confirm the claim with one or two tool calls, drop the finding.",
83
+ "",
84
+ "Keep a finding only after step 3 succeeds. Otherwise drop it.",
85
+ "",
86
+ "Output format — copy spacing and case exactly, do not wrap bullets in backticks:",
87
+ "- [high|medium] <short title>",
88
+ " File: <path>",
89
+ " Line: ~<N>",
90
+ " Why: <one sentence pointing at the evidence you confirmed>",
91
+ "",
92
+ "Example — KEEP this finding because get_file_at_ref shows the cited code at refs.head:",
93
+ "- [high] Null pointer dereference in user lookup",
94
+ " File: src/user/service.ts",
95
+ " Line: ~42",
96
+ " Why: getUser() returns null on miss and the next line calls user.id without a guard (confirmed in src/user/service.ts at refs.head).",
97
+ "",
98
+ "Example — DROP a finding when refs.head shows the bug is already gone (do not output it).",
99
+ "",
100
+ `If after verification nothing survives, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
101
+ ];
102
+ }
103
+ export function buildConsolidateSystemLines(maxFindings) {
104
+ return buildConsolidateDefaultLines(maxFindings);
105
+ }
106
+ export function buildVerificationSystemLines(maxFindings) {
107
+ return buildVerificationDefaultLines(maxFindings);
108
+ }
109
+ export function getConsolidateSystemLines(profile, maxFindings) {
110
+ return profile === "weak"
111
+ ? buildConsolidateWeakLines(maxFindings)
112
+ : buildConsolidateDefaultLines(maxFindings);
113
+ }
114
+ export function getVerificationSystemLines(profile, maxFindings) {
115
+ return profile === "weak"
116
+ ? buildVerificationWeakLines(maxFindings)
117
+ : buildVerificationDefaultLines(maxFindings);
118
+ }
@@ -1,5 +1,6 @@
1
1
  /** @format */
2
- export const MAIN_SYSTEM_LINES = [
2
+ import { NO_FINDINGS_SENTENCE } from "../profile.js";
3
+ const MAIN_DEFAULT_LINES = [
3
4
  "You are an AI code reviewer for pull requests.",
4
5
  "Find only real bugs introduced by the diff.",
5
6
  "Return at most 3 findings. Prefer no finding over a weak one.",
@@ -9,7 +10,7 @@ export const MAIN_SYSTEM_LINES = [
9
10
  "- Ignore style, refactoring suggestions, and general best practices.",
10
11
  "- If uncertain, use tools: get_file_at_ref and grep_repository.",
11
12
  "- Report only issues clearly visible in diff or verified by tools.",
12
- '- If uncertain after checking, return exactly: "No confirmed bugs or high-value optimizations found."',
13
+ `- If uncertain after checking, return exactly: "${NO_FINDINGS_SENTENCE}"`,
13
14
  "",
14
15
  "Severity:",
15
16
  "- [high]: deterministic runtime/security breakage with clear path.",
@@ -20,9 +21,40 @@ export const MAIN_SYSTEM_LINES = [
20
21
  "- ` File: <path>`",
21
22
  "- ` Line: ~<N>`",
22
23
  "- ` Why: <one concise sentence with evidence>`",
23
- '- If no issues: exactly "No confirmed bugs or high-value optimizations found."',
24
+ `- If no issues: exactly "${NO_FINDINGS_SENTENCE}"`,
24
25
  ];
25
- export const FILE_REVIEW_SYSTEM_LINES = [
26
+ const MAIN_WEAK_LINES = [
27
+ "You are an AI bug-finder for a merge request.",
28
+ "Output bullets only — no preamble, no headings, no code fences, no closing remarks.",
29
+ "Return at most 3 findings.",
30
+ "",
31
+ "Report a finding ONLY when ALL of these are true:",
32
+ "1. The bug is on a line added or modified in the diff (or a direct consequence of one).",
33
+ "2. You can quote the offending text from the diff or from get_file_at_ref output.",
34
+ "3. The bug causes wrong behavior, a crash, a security issue, or data loss — not a style nit.",
35
+ "If any of those is not true, do not include the finding.",
36
+ "",
37
+ "When the diff is unclear, call the tools: get_file_at_ref(path, ref) or grep_repository(query, ref). Prefer the head ref.",
38
+ "",
39
+ "Severity tags:",
40
+ "- [high] = will break at runtime or leak data.",
41
+ "- [medium] = strong evidence of a bug, but conditions matter.",
42
+ "",
43
+ "Output format — copy spacing and case exactly, do not wrap in backticks or fences:",
44
+ "- [high|medium] <short title>",
45
+ " File: <path>",
46
+ " Line: ~<N>",
47
+ " Why: <one sentence pointing at the evidence>",
48
+ "",
49
+ "Example of a valid finding:",
50
+ "- [high] Null pointer dereference in user lookup",
51
+ " File: src/user/service.ts",
52
+ " Line: ~42",
53
+ " Why: getUser() can return null but the next line calls user.id without a guard.",
54
+ "",
55
+ `If you cannot meet all three conditions for any finding, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
56
+ ];
57
+ const FILE_DEFAULT_LINES = [
26
58
  "You are an AI reviewer for a single-file diff.",
27
59
  "Find only real bugs introduced by changed lines.",
28
60
  "Return at most 2 findings. Prefer no finding over a weak one.",
@@ -32,7 +64,7 @@ export const FILE_REVIEW_SYSTEM_LINES = [
32
64
  "- Ignore style/refactor/general improvement comments.",
33
65
  "- If uncertain, use tools: get_file_at_ref and grep_repository.",
34
66
  "- Report only issues clearly visible in diff or verified by tools.",
35
- '- If uncertain after checking, return exactly: "No issues found."',
67
+ `- If uncertain after checking, return exactly: "${NO_FINDINGS_SENTENCE}"`,
36
68
  "",
37
69
  "Severity:",
38
70
  "- [high]: deterministic runtime/security breakage with clear path.",
@@ -43,5 +75,46 @@ export const FILE_REVIEW_SYSTEM_LINES = [
43
75
  "- ` File: <path>`",
44
76
  "- ` Line: ~<N>`",
45
77
  "- ` Why: <one concise sentence with evidence>`",
46
- '- If no issues: exactly "No issues found."',
78
+ `- If no issues: exactly "${NO_FINDINGS_SENTENCE}"`,
79
+ ];
80
+ const FILE_WEAK_LINES = [
81
+ "You are an AI bug-finder reviewing one file's diff.",
82
+ "Output bullets only — no preamble, no headings, no code fences.",
83
+ "Return at most 2 findings.",
84
+ "",
85
+ "Report a finding ONLY when ALL of these are true:",
86
+ "1. The bug is on a line added or modified in this file's diff.",
87
+ "2. You can quote the offending text from the diff or from get_file_at_ref output.",
88
+ "3. The bug causes wrong behavior, a crash, a security issue, or data loss.",
89
+ "If any of those is not true, do not include the finding.",
90
+ "",
91
+ "When the diff is unclear, call the tools: get_file_at_ref(path, ref) or grep_repository(query, ref). Prefer the head ref.",
92
+ "",
93
+ "Severity tags:",
94
+ "- [high] = will break at runtime or leak data.",
95
+ "- [medium] = strong evidence of a bug, but conditions matter.",
96
+ "",
97
+ "Output format — copy spacing and case exactly, do not wrap in backticks or fences:",
98
+ "- [high|medium] <short title>",
99
+ " File: <path>",
100
+ " Line: ~<N>",
101
+ " Why: <one sentence pointing at the evidence>",
102
+ "",
103
+ "Example of a valid finding:",
104
+ "- [medium] Off-by-one in pagination loop",
105
+ " File: src/api/list.ts",
106
+ " Line: ~88",
107
+ " Why: loop runs while i <= total but indices are 0-based, so the last item is read past the array end.",
108
+ "",
109
+ `If you cannot meet all three conditions for any finding, output exactly this single line and nothing else: ${NO_FINDINGS_SENTENCE}`,
47
110
  ];
111
+ export function getMainSystemLines(profile) {
112
+ return profile === "weak" ? MAIN_WEAK_LINES : MAIN_DEFAULT_LINES;
113
+ }
114
+ export function getFileReviewSystemLines(profile) {
115
+ return profile === "weak" ? FILE_WEAK_LINES : FILE_DEFAULT_LINES;
116
+ }
117
+ /** @deprecated kept for backward compatibility — use getMainSystemLines("default"). */
118
+ export const MAIN_SYSTEM_LINES = MAIN_DEFAULT_LINES;
119
+ /** @deprecated kept for backward compatibility — use getFileReviewSystemLines("default"). */
120
+ export const FILE_REVIEW_SYSTEM_LINES = FILE_DEFAULT_LINES;
@@ -1,11 +1,11 @@
1
1
  /** @format */
2
- export const TRIAGE_SYSTEM_LINES = [
2
+ const DEFAULT_LINES = [
3
3
  "You are a senior developer triaging files in a merge request.",
4
4
  "For each file, decide whether it NEEDS_REVIEW (modifies logic, functionality, security, or performance) or can be SKIPPED (cosmetic-only: formatting, comments, renaming for clarity, docs, auto-generated files).",
5
5
  "",
6
6
  "Also produce a concise summary (2-4 sentences) of the entire merge request: what it does and which areas it touches.",
7
7
  "",
8
- "Respond with a JSON object (no markdown fences) in this exact schema:",
8
+ "Respond with a JSON object (no markdown fences, no prose before or after) in this exact schema:",
9
9
  '{ "summary": "<MR summary>", "files": [{ "path": "<file path>", "verdict": "NEEDS_REVIEW" | "SKIP" }] }',
10
10
  "",
11
11
  "Rules:",
@@ -18,4 +18,31 @@ export const TRIAGE_SYSTEM_LINES = [
18
18
  "- Test files that add, remove, or change assertions or expected values are NEEDS_REVIEW.",
19
19
  "- Test-only files are SKIP only if changes are purely cosmetic (formatting, comments).",
20
20
  "- Config/CI/docs files are SKIP unless they modify build targets, env vars, or secrets.",
21
+ "",
22
+ "Example output (copy this style exactly — single JSON object, no fences, no preamble):",
23
+ '{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW"},{"path":"README.md","verdict":"SKIP"}]}',
24
+ ];
25
+ const WEAK_LINES = [
26
+ "You triage merge-request files. Output one JSON object only — no prose, no markdown, no code fences, no preamble.",
27
+ "",
28
+ "Schema (use exactly these keys):",
29
+ '{"summary":"<2-3 sentences about the MR>","files":[{"path":"<file path>","verdict":"NEEDS_REVIEW" | "SKIP"}]}',
30
+ "",
31
+ "Mark a file NEEDS_REVIEW when ANY of these is true:",
32
+ "- the diff adds or removes non-comment lines,",
33
+ "- the diff changes a function signature, return type, control flow, or variable value,",
34
+ "- the file is new and contains code,",
35
+ "- a test changes an assertion or expected value,",
36
+ "- a config/CI file changes build targets, env vars, or secrets.",
37
+ "",
38
+ "Mark a file SKIP only when the diff is purely cosmetic: whitespace, comments, JSDoc, plain docs, auto-generated files, or deletions that cannot affect callers.",
39
+ "When in doubt, choose NEEDS_REVIEW.",
40
+ "",
41
+ "Example output (copy this exactly — one line, one JSON object, nothing else):",
42
+ '{"summary":"Adds retry-with-backoff to the OpenAI client and updates the README badge.","files":[{"path":"src/openai/client.ts","verdict":"NEEDS_REVIEW"},{"path":"README.md","verdict":"SKIP"}]}',
21
43
  ];
44
+ export function getTriageSystemLines(profile) {
45
+ return profile === "weak" ? WEAK_LINES : DEFAULT_LINES;
46
+ }
47
+ /** @deprecated kept for backward compatibility — use getTriageSystemLines("default"). */
48
+ export const TRIAGE_SYSTEM_LINES = DEFAULT_LINES;
@@ -1,4 +1,46 @@
1
1
  /** @format */
2
+ /** Extract the first balanced JSON object substring from arbitrary text.
3
+ * Useful when an LLM wraps JSON in prose ("Here is the result: { ... }").
4
+ * Returns null if no balanced object is found. */
5
+ export function extractFirstJsonObject(input) {
6
+ let depth = 0;
7
+ let start = -1;
8
+ let inString = false;
9
+ let escape = false;
10
+ for (let i = 0; i < input.length; i += 1) {
11
+ const ch = input[i];
12
+ if (inString) {
13
+ if (escape) {
14
+ escape = false;
15
+ }
16
+ else if (ch === "\\") {
17
+ escape = true;
18
+ }
19
+ else if (ch === '"') {
20
+ inString = false;
21
+ }
22
+ continue;
23
+ }
24
+ if (ch === '"') {
25
+ inString = true;
26
+ continue;
27
+ }
28
+ if (ch === "{") {
29
+ if (depth === 0)
30
+ start = i;
31
+ depth += 1;
32
+ }
33
+ else if (ch === "}") {
34
+ if (depth === 0)
35
+ continue;
36
+ depth -= 1;
37
+ if (depth === 0 && start >= 0) {
38
+ return input.slice(start, i + 1);
39
+ }
40
+ }
41
+ }
42
+ return null;
43
+ }
2
44
  export function truncateWithMarker(value, maxChars, markerLabel) {
3
45
  if (value.length <= maxChars)
4
46
  return value;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@krotovm/gitlab-ai-review",
4
- "version": "1.0.30",
4
+ "version": "1.0.33",
5
5
  "description": "CLI tool to generate AI code reviews for GitLab merge requests.",
6
6
  "main": "dist/cli.js",
7
7
  "bin": {