gitxplain 0.1.8 → 0.1.9

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.
@@ -1,34 +1,4 @@
1
- import process from "node:process";
2
-
3
- const ANSI = {
4
- reset: "\u001b[0m",
5
- bold: "\u001b[1m",
6
- cyan: "\u001b[36m",
7
- yellow: "\u001b[33m",
8
- green: "\u001b[32m",
9
- red: "\u001b[31m",
10
- gray: "\u001b[90m"
11
- };
12
-
13
- function supportsColor() {
14
- if (process.env.FORCE_COLOR != null && process.env.FORCE_COLOR !== "0") {
15
- return true;
16
- }
17
-
18
- if (process.env.NO_COLOR != null) {
19
- return false;
20
- }
21
-
22
- return Boolean(process.stdout?.isTTY);
23
- }
24
-
25
- function colorize(text, color) {
26
- if (!supportsColor()) {
27
- return text;
28
- }
29
-
30
- return `${color}${text}${ANSI.reset}`;
31
- }
1
+ import { ANSI, colorize } from "./colorSupport.js";
32
2
 
33
3
  function stripInlineMarkdown(text) {
34
4
  return text
@@ -84,61 +54,37 @@ function normalizeMarkdownLine(line, state) {
84
54
  }
85
55
 
86
56
  function formatTargetLabel(commitData) {
87
- return commitData.analysisType === "range" ? "Range" : "Commit";
88
- }
89
-
90
- function normalizeHeading(line) {
91
- const match = line.match(/^([0-9]+\.)?\s*(Summary|Issues? Fixed|Issue|Root Cause|Fix(?: Explanation)?|Impact|Risk Level|Severity|Technical Breakdown|Full Analysis|Line-by-Line Code Walkthrough|Code Review|Security Review|Security Findings|Review Findings|Suggestions|Recommended Mitigations)\s*:?\s*$/i);
92
-
93
- if (!match) {
94
- return null;
57
+ if (commitData.analysisType === "range") {
58
+ return "Range";
95
59
  }
96
60
 
97
- return `${match[2]}:`;
98
- }
99
-
100
- function isFileHeading(line) {
101
- return /^(?:File|Path)\s*:/i.test(line) || /^[A-Za-z0-9_./-]+\.[A-Za-z0-9]+:\s*$/.test(line);
102
- }
103
-
104
- function classifyTone(line) {
105
- if (/^\s*low(?:\b|[.:])/i.test(line)) {
106
- return "good";
61
+ if (commitData.analysisType === "blame") {
62
+ return "File";
107
63
  }
108
64
 
109
- if (/^\s*medium(?:\b|[.:])/i.test(line)) {
110
- return "neutral";
65
+ if (commitData.analysisType === "stash") {
66
+ return "Stash";
111
67
  }
112
68
 
113
- if (/^\s*high(?:\b|[.:])/i.test(line)) {
114
- return "bad";
69
+ if (commitData.analysisType === "conflict") {
70
+ return "Conflict";
115
71
  }
116
72
 
117
- if (
118
- /\b(no significant findings|no security findings|none apparent|looks good|safe|improved|improvement|fixed|resolved|successful|passes?|low risk|low severity)\b/i.test(
119
- line
120
- )
121
- ) {
122
- return "good";
123
- }
73
+ return "Commit";
74
+ }
124
75
 
125
- if (
126
- /\b(issue|issues|bug|broken|failure|failing|risk|risky|severity|vulnerability|insecure|regression|warning|problem|bad|missing|error|high risk|high severity)\b/i.test(
127
- line
128
- )
129
- ) {
130
- return "bad";
131
- }
76
+ function normalizeHeading(line) {
77
+ const match = line.match(/^([0-9]+\.)?\s*(Summary|Issues? Fixed|Issue|Root Cause|Fix(?: Explanation)?|Impact|Risk Level|Severity|Technical Breakdown|Full Analysis|Line-by-Line Code Walkthrough|Code Review|Security Review|Security Findings|Review Findings|Suggestions|Recommended Mitigations)\s*:?\s*$/i);
132
78
 
133
- if (/\b(suggestion|consider|follow-up|todo|medium risk|medium severity)\b/i.test(line)) {
134
- return "neutral";
79
+ if (!match) {
80
+ return null;
135
81
  }
136
82
 
137
- return null;
83
+ return `${match[2]}:`;
138
84
  }
139
85
 
140
- function colorizeByTone(line, tone) {
141
- return line;
86
+ function isFileHeading(line) {
87
+ return /^(?:File|Path)\s*:/i.test(line) || /^[A-Za-z0-9_./-]+\.[A-Za-z0-9]+:\s*$/.test(line);
142
88
  }
143
89
 
144
90
  function formatBulletLine(line) {
@@ -189,7 +135,7 @@ function formatLine(line) {
189
135
  return severityLine;
190
136
  }
191
137
 
192
- return colorizeByTone(line, classifyTone(line));
138
+ return line;
193
139
  }
194
140
 
195
141
  function formatExplanation(explanation) {
@@ -266,6 +212,10 @@ export function formatFooter({ responseMeta, promptMeta, options }) {
266
212
  lines.push(`Usage: ${JSON.stringify(responseMeta.usage)}`);
267
213
  }
268
214
 
215
+ if (responseMeta.estimatedCostUsd != null) {
216
+ lines.push(`Estimated Cost: $${responseMeta.estimatedCostUsd.toFixed(6)}`);
217
+ }
218
+
269
219
  if (promptMeta?.warnings?.length) {
270
220
  lines.push(...promptMeta.warnings);
271
221
  }
@@ -414,6 +414,24 @@ export function inspectRepositoryForPipeline(cwd) {
414
414
  label: "GitHub Actions CI verification",
415
415
  description: "Runs install, lint, test, build, and package checks when supported.",
416
416
  files: [".github/workflows/ci.yml"]
417
+ },
418
+ {
419
+ id: "gitlab-ci",
420
+ label: "GitLab CI verification",
421
+ description: "Creates a .gitlab-ci.yml pipeline with install, lint, test, and build stages.",
422
+ files: [".gitlab-ci.yml"]
423
+ },
424
+ {
425
+ id: "circleci",
426
+ label: "CircleCI verification",
427
+ description: "Creates a .circleci/config.yml pipeline for verification jobs.",
428
+ files: [".circleci/config.yml"]
429
+ },
430
+ {
431
+ id: "bitbucket-pipelines",
432
+ label: "Bitbucket Pipelines verification",
433
+ description: "Creates bitbucket-pipelines.yml with install, test, and build steps.",
434
+ files: ["bitbucket-pipelines.yml"]
417
435
  }
418
436
  ];
419
437
 
@@ -676,6 +694,87 @@ export function buildContainerWorkflow() {
676
694
  ].join("\n").concat("\n");
677
695
  }
678
696
 
697
+ function buildPipelineCommands(context) {
698
+ return [context.commands.install, context.commands.lint, context.commands.test, context.commands.build, context.commands.pack]
699
+ .filter(Boolean);
700
+ }
701
+
702
+ export function buildGitLabCiWorkflow(context) {
703
+ const commands = buildPipelineCommands(context);
704
+ const image =
705
+ context.type === "python"
706
+ ? "python:3.12"
707
+ : context.type === "go"
708
+ ? "golang:1.22"
709
+ : context.type === "rust"
710
+ ? "rust:latest"
711
+ : "node:20";
712
+
713
+ return [
714
+ `image: ${image}`,
715
+ "",
716
+ "stages:",
717
+ " - verify",
718
+ "",
719
+ "verify:",
720
+ " stage: verify",
721
+ " script:",
722
+ ...commands.map((command) => ` - ${command}`)
723
+ ].join("\n").concat("\n");
724
+ }
725
+
726
+ export function buildCircleCiWorkflow(context) {
727
+ const image =
728
+ context.type === "python"
729
+ ? "cimg/python:3.12"
730
+ : context.type === "go"
731
+ ? "cimg/go:1.22"
732
+ : context.type === "rust"
733
+ ? "cimg/rust:1.83"
734
+ : "cimg/node:20.10";
735
+ const commands = buildPipelineCommands(context);
736
+
737
+ return [
738
+ "version: 2.1",
739
+ "",
740
+ "jobs:",
741
+ " verify:",
742
+ " docker:",
743
+ ` - image: ${image}`,
744
+ " steps:",
745
+ " - checkout",
746
+ ...commands.map((command) => ` - run: ${command}`),
747
+ "",
748
+ "workflows:",
749
+ " verify:",
750
+ " jobs:",
751
+ " - verify"
752
+ ].join("\n").concat("\n");
753
+ }
754
+
755
+ export function buildBitbucketPipelinesWorkflow(context) {
756
+ const image =
757
+ context.type === "python"
758
+ ? "python:3.12"
759
+ : context.type === "go"
760
+ ? "golang:1.22"
761
+ : context.type === "rust"
762
+ ? "rust:latest"
763
+ : "node:20";
764
+ const commands = buildPipelineCommands(context);
765
+
766
+ return [
767
+ `image: ${image}`,
768
+ "",
769
+ "pipelines:",
770
+ " default:",
771
+ " - step:",
772
+ " name: Verify",
773
+ " script:",
774
+ ...commands.map((command) => ` - ${command}`)
775
+ ].join("\n").concat("\n");
776
+ }
777
+
679
778
  export function writePipelineFiles(cwd, analysis, selection) {
680
779
  if (!analysis.supported) {
681
780
  throw new Error(analysis.reason);
@@ -689,6 +788,7 @@ export function writePipelineFiles(cwd, analysis, selection) {
689
788
 
690
789
  const writeWorkflow = (relativePath, contents) => {
691
790
  const absolutePath = path.join(cwd, relativePath);
791
+ mkdirSync(path.dirname(absolutePath), { recursive: true });
692
792
  writeFileSync(absolutePath, contents, "utf8");
693
793
  writtenFiles.push(relativePath);
694
794
  };
@@ -713,6 +813,18 @@ export function writePipelineFiles(cwd, analysis, selection) {
713
813
  writeWorkflow(".github/workflows/container.yml", buildContainerWorkflow());
714
814
  }
715
815
 
816
+ if (selection.id === "gitlab-ci") {
817
+ writeWorkflow(".gitlab-ci.yml", buildGitLabCiWorkflow(analysis.primary));
818
+ }
819
+
820
+ if (selection.id === "circleci") {
821
+ writeWorkflow(".circleci/config.yml", buildCircleCiWorkflow(analysis.primary));
822
+ }
823
+
824
+ if (selection.id === "bitbucket-pipelines") {
825
+ writeWorkflow("bitbucket-pipelines.yml", buildBitbucketPipelinesWorkflow(analysis.primary));
826
+ }
827
+
716
828
  if (selection.id === "container" && !selection.files.includes(".github/workflows/ci.yml")) {
717
829
  notes.push("This option only creates the container workflow. Run `gitxplain --pipeline` again if you also want CI verification.");
718
830
  }
@@ -16,7 +16,14 @@ const PROMPT_FILES = {
16
16
  review: "review.txt",
17
17
  security: "security.txt",
18
18
  split: "split.txt",
19
- commit: "commit.txt"
19
+ commit: "commit.txt",
20
+ changelog: "changelog.txt",
21
+ refactor: "refactor.txt",
22
+ "test-suggest": "test-suggest.txt",
23
+ "pr-description": "pr-description.txt",
24
+ blame: "blame.txt",
25
+ stash: "stash.txt",
26
+ conflict: "conflict.txt"
20
27
  };
21
28
 
22
29
  function fillTemplate(template, values) {
@@ -1,4 +1,3 @@
1
- import process from "node:process";
2
1
  import {
3
2
  createCommitFromTree,
4
3
  deletePaths,
@@ -32,26 +31,7 @@ import {
32
31
  runGitCommandUnchecked,
33
32
  resolveCommitSha
34
33
  } from "./gitService.js";
35
-
36
- const ANSI = {
37
- reset: "\u001b[0m",
38
- bold: "\u001b[1m",
39
- cyan: "\u001b[36m",
40
- yellow: "\u001b[33m",
41
- green: "\u001b[32m"
42
- };
43
-
44
- function supportsColor() {
45
- return Boolean(process.stdout?.isTTY) && process.env.NO_COLOR == null;
46
- }
47
-
48
- function colorize(text, color) {
49
- if (!supportsColor()) {
50
- return text;
51
- }
52
-
53
- return `${color}${text}${ANSI.reset}`;
54
- }
34
+ import { ANSI, colorize } from "./colorSupport.js";
55
35
 
56
36
  function extractJsonPayload(explanation) {
57
37
  const fencedMatch = explanation.match(/```(?:json)?\s*([\s\S]*?)\s*```/i);
@@ -0,0 +1,158 @@
1
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ function getUsageLogPath() {
6
+ return path.join(os.homedir(), ".gitxplain", "usage.jsonl");
7
+ }
8
+
9
+ export function getUsageLogFile() {
10
+ return getUsageLogPath();
11
+ }
12
+
13
+ function readRecords() {
14
+ const filePath = getUsageLogPath();
15
+ if (!existsSync(filePath)) {
16
+ return [];
17
+ }
18
+
19
+ return readFileSync(filePath, "utf8")
20
+ .split("\n")
21
+ .map((line) => line.trim())
22
+ .filter(Boolean)
23
+ .map((line) => JSON.parse(line));
24
+ }
25
+
26
+ function parseNumeric(value) {
27
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
28
+ }
29
+
30
+ export function normalizeUsageMetrics(usage) {
31
+ if (!usage || typeof usage !== "object") {
32
+ return {
33
+ inputTokens: 0,
34
+ outputTokens: 0,
35
+ totalTokens: 0
36
+ };
37
+ }
38
+
39
+ const inputTokens =
40
+ parseNumeric(usage.prompt_tokens) ||
41
+ parseNumeric(usage.input_tokens) ||
42
+ parseNumeric(usage.promptTokenCount);
43
+ const outputTokens =
44
+ parseNumeric(usage.completion_tokens) ||
45
+ parseNumeric(usage.output_tokens) ||
46
+ parseNumeric(usage.candidatesTokenCount);
47
+ const totalTokens =
48
+ parseNumeric(usage.total_tokens) ||
49
+ parseNumeric(usage.totalTokenCount) ||
50
+ inputTokens + outputTokens;
51
+
52
+ return {
53
+ inputTokens,
54
+ outputTokens,
55
+ totalTokens
56
+ };
57
+ }
58
+
59
+ function parseEnvPrice(envKey) {
60
+ const raw = process.env[envKey];
61
+ if (raw == null || raw === "") {
62
+ return null;
63
+ }
64
+
65
+ const parsed = Number.parseFloat(raw);
66
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
67
+ }
68
+
69
+ export function resolvePricing(config) {
70
+ const providerKey = config.provider.toUpperCase().replace(/[^A-Z0-9]+/g, "_");
71
+ const modelKey = String(config.model ?? "default").toUpperCase().replace(/[^A-Z0-9]+/g, "_");
72
+
73
+ const inputPerMillion =
74
+ parseEnvPrice(`${providerKey}_${modelKey}_INPUT_COST_PER_MTOK`) ??
75
+ parseEnvPrice(`${providerKey}_INPUT_COST_PER_MTOK`) ??
76
+ parseEnvPrice("LLM_INPUT_COST_PER_MTOK");
77
+ const outputPerMillion =
78
+ parseEnvPrice(`${providerKey}_${modelKey}_OUTPUT_COST_PER_MTOK`) ??
79
+ parseEnvPrice(`${providerKey}_OUTPUT_COST_PER_MTOK`) ??
80
+ parseEnvPrice("LLM_OUTPUT_COST_PER_MTOK");
81
+
82
+ if (inputPerMillion == null || outputPerMillion == null) {
83
+ return null;
84
+ }
85
+
86
+ return {
87
+ inputPerMillion,
88
+ outputPerMillion
89
+ };
90
+ }
91
+
92
+ export function estimateCostUsd(usage, pricing) {
93
+ if (!pricing) {
94
+ return null;
95
+ }
96
+
97
+ const metrics = normalizeUsageMetrics(usage);
98
+ const costUsd =
99
+ (metrics.inputTokens / 1_000_000) * pricing.inputPerMillion +
100
+ (metrics.outputTokens / 1_000_000) * pricing.outputPerMillion;
101
+
102
+ return Number.isFinite(costUsd) ? costUsd : null;
103
+ }
104
+
105
+ export function appendUsageRecord({ provider, model, usage, latencyMs, estimatedCostUsd }) {
106
+ const metrics = normalizeUsageMetrics(usage);
107
+ if (metrics.totalTokens === 0 && estimatedCostUsd == null) {
108
+ return;
109
+ }
110
+
111
+ const filePath = getUsageLogPath();
112
+ mkdirSync(path.dirname(filePath), { recursive: true });
113
+ appendFileSync(
114
+ filePath,
115
+ `${JSON.stringify({
116
+ timestamp: new Date().toISOString(),
117
+ provider,
118
+ model,
119
+ usage: metrics,
120
+ latencyMs: latencyMs ?? null,
121
+ estimatedCostUsd
122
+ })}\n`,
123
+ "utf8"
124
+ );
125
+ }
126
+
127
+ export function getUsageStats() {
128
+ const records = readRecords();
129
+
130
+ return records.reduce(
131
+ (summary, record) => {
132
+ summary.requestCount += 1;
133
+ summary.inputTokens += parseNumeric(record.usage?.inputTokens);
134
+ summary.outputTokens += parseNumeric(record.usage?.outputTokens);
135
+ summary.totalTokens += parseNumeric(record.usage?.totalTokens);
136
+ summary.estimatedCostUsd += parseNumeric(record.estimatedCostUsd);
137
+ return summary;
138
+ },
139
+ {
140
+ requestCount: 0,
141
+ inputTokens: 0,
142
+ outputTokens: 0,
143
+ totalTokens: 0,
144
+ estimatedCostUsd: 0
145
+ }
146
+ );
147
+ }
148
+
149
+ export function clearUsageLog() {
150
+ const filePath = getUsageLogPath();
151
+ const count = readRecords().length;
152
+
153
+ if (existsSync(filePath)) {
154
+ rmSync(filePath, { force: true });
155
+ }
156
+
157
+ return count;
158
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gitxplain",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "description": "AI-powered Git commit explainer CLI",
5
5
  "type": "module",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  },
10
10
  "scripts": {
11
11
  "start": "node ./cli/index.js",
12
- "lint": "node --check ./cli/index.js && node --check ./cli/services/chatService.js && node --check ./cli/services/gitConnectionService.js && node --check ./cli/services/envLoader.js && node --check ./cli/services/pipelineService.js",
12
+ "lint": "node --check ./cli/index.js && node --check ./cli/services/envLoader.js && node --check ./cli/services/pipelineService.js",
13
13
  "test": "node --test"
14
14
  },
15
15
  "keywords": [
@@ -0,0 +1,29 @@
1
+ Analyze this git blame report for a file and explain ownership patterns.
2
+
3
+ Context:
4
+ - The input below is not a diff. It is a compact blame summary built from `git blame --line-porcelain`.
5
+ - Focus on who changed which parts of the file, when the major edits happened, and what kinds of changes appear to have shaped the file.
6
+ - Call out concentrated ownership, legacy areas, and spots that may need extra care during onboarding or refactoring.
7
+
8
+ Commit Message:
9
+ {{commit_message}}
10
+
11
+ Files Changed:
12
+ {{files_changed}}
13
+
14
+ Stats:
15
+ {{stats}}
16
+
17
+ Blame Report:
18
+ {{diff}}
19
+
20
+ Return:
21
+
22
+ 1. Ownership Summary:
23
+ - Summarize the main authors or time periods that shaped the file
24
+
25
+ 2. Likely Change Themes:
26
+ - Explain what kinds of changes the major contributors appear to have made
27
+
28
+ 3. Onboarding Notes:
29
+ - Suggest where a new maintainer should read carefully, who to ask first, or what parts look risky or stable
@@ -0,0 +1,36 @@
1
+ Generate release notes in a conventional-changelog style.
2
+
3
+ Context:
4
+ - This may represent a single commit or a range of commits.
5
+ - Prefer grouping entries under headings like Features, Fixes, Refactors, Docs, Tests, Chores, and Breaking Changes when supported by the evidence.
6
+ - Use commit messages first, then use the diff to sharpen unclear items.
7
+ - Be conservative and do not invent user-facing changes that are not visible in the Git data.
8
+
9
+ Commit Message:
10
+ {{commit_message}}
11
+
12
+ Files Changed:
13
+ {{files_changed}}
14
+
15
+ Stats:
16
+ {{stats}}
17
+
18
+ Change Hints:
19
+ {{change_hints}}
20
+
21
+ Diff:
22
+ {{diff}}
23
+
24
+ Return:
25
+
26
+ 1. Release Summary:
27
+ - One short paragraph describing the overall release/change set
28
+
29
+ 2. Changelog:
30
+ - Group entries under relevant categories
31
+ - Keep bullets concise and user-facing
32
+ - If there are no clear entries for a category, omit it
33
+
34
+ 3. Upgrade Notes:
35
+ - Mention any migration steps, rollout cautions, or compatibility risks
36
+ - If none are apparent, say so clearly
@@ -0,0 +1,33 @@
1
+ You are helping resolve unresolved Git merge conflicts.
2
+
3
+ Context:
4
+ - The input below is a structured report extracted from conflict markers in the working tree.
5
+ - Explain what each side appears to be doing, suggest the most likely safe resolution, and call out any ambiguity that requires human review.
6
+ - Be conservative. Do not pretend a single resolution is certain when the intent is unclear.
7
+
8
+ Conflict Summary:
9
+ {{commit_message}}
10
+
11
+ Files Changed:
12
+ {{files_changed}}
13
+
14
+ Stats:
15
+ {{stats}}
16
+
17
+ Conflict Report:
18
+ {{diff}}
19
+
20
+ Return:
21
+
22
+ 1. Conflict Summary:
23
+ - Explain the overall source of the conflict
24
+
25
+ 2. Suggested Resolution:
26
+ - For each file or conflict block, describe the most likely resolution
27
+ - Be explicit about which lines to keep, merge, or rewrite
28
+
29
+ 3. Why:
30
+ - Explain why the suggested resolution is the safest or most consistent
31
+
32
+ 4. Follow-Up Checks:
33
+ - Suggest tests, manual verification steps, or edge cases to validate after resolving the conflict
@@ -0,0 +1,40 @@
1
+ Write a pull request description for this change set.
2
+
3
+ Context:
4
+ - This may be a single commit, a commit range, or a branch comparison.
5
+ - The result should be ready to paste into GitHub or GitLab.
6
+ - Be accurate and concise. Do not invent testing or screenshots that are not evidenced by the diff.
7
+
8
+ Commit Message:
9
+ {{commit_message}}
10
+
11
+ Files Changed:
12
+ {{files_changed}}
13
+
14
+ Stats:
15
+ {{stats}}
16
+
17
+ Change Hints:
18
+ {{change_hints}}
19
+
20
+ Diff:
21
+ {{diff}}
22
+
23
+ Return:
24
+
25
+ ## Summary
26
+ - Short bullets describing the main changes
27
+
28
+ ## Why
29
+ - Why this PR exists
30
+
31
+ ## Testing
32
+ - Mention visible testing evidence from the diff if any
33
+ - If no testing evidence is visible, say "Not specified in diff"
34
+
35
+ ## Risks
36
+ - Briefly describe rollout, compatibility, or review risks
37
+
38
+ ## Screenshots
39
+ - If the diff suggests UI work, include "Screenshots: Needed"
40
+ - Otherwise include "Screenshots: Not needed"
@@ -0,0 +1,29 @@
1
+ Review this change for refactoring opportunities.
2
+
3
+ Commit Message:
4
+ {{commit_message}}
5
+
6
+ Files Changed:
7
+ {{files_changed}}
8
+
9
+ Stats:
10
+ {{stats}}
11
+
12
+ Change Hints:
13
+ {{change_hints}}
14
+
15
+ Diff:
16
+ {{diff}}
17
+
18
+ Return:
19
+
20
+ 1. Refactor Opportunities:
21
+ - List concrete refactors suggested by the code shown
22
+ - Focus on duplication, naming, cohesion, dead code, control flow, error handling, and API clarity
23
+ - If there are no worthwhile refactors, say so explicitly
24
+
25
+ 2. Why These Matter:
26
+ - Explain the maintenance or correctness benefits of the top opportunities
27
+
28
+ 3. Safe Next Steps:
29
+ - Suggest a small, practical follow-up plan that avoids mixing refactors with risky behavior changes