ghagga-core 2.9.1 → 3.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.
Files changed (175) hide show
  1. package/LICENSE +21 -0
  2. package/dist/agents/consensus.d.ts.map +1 -1
  3. package/dist/agents/consensus.js +7 -2
  4. package/dist/agents/consensus.js.map +1 -1
  5. package/dist/agents/diagnostic.d.ts.map +1 -1
  6. package/dist/agents/diagnostic.js +7 -2
  7. package/dist/agents/diagnostic.js.map +1 -1
  8. package/dist/agents/fan-out-lenses.d.ts.map +1 -1
  9. package/dist/agents/fan-out-lenses.js +7 -2
  10. package/dist/agents/fan-out-lenses.js.map +1 -1
  11. package/dist/agents/prompts.d.ts +49 -1
  12. package/dist/agents/prompts.d.ts.map +1 -1
  13. package/dist/agents/prompts.js +133 -5
  14. package/dist/agents/prompts.js.map +1 -1
  15. package/dist/agents/simple.d.ts +1 -1
  16. package/dist/agents/simple.d.ts.map +1 -1
  17. package/dist/agents/simple.js +6 -4
  18. package/dist/agents/simple.js.map +1 -1
  19. package/dist/agents/workflow.d.ts.map +1 -1
  20. package/dist/agents/workflow.js +13 -4
  21. package/dist/agents/workflow.js.map +1 -1
  22. package/dist/critique/critique.d.ts.map +1 -1
  23. package/dist/critique/critique.js +14 -6
  24. package/dist/critique/critique.js.map +1 -1
  25. package/dist/diff/index.d.ts +12 -0
  26. package/dist/diff/index.d.ts.map +1 -0
  27. package/dist/diff/index.js +11 -0
  28. package/dist/diff/index.js.map +1 -0
  29. package/dist/diff/parse.d.ts +41 -0
  30. package/dist/diff/parse.d.ts.map +1 -0
  31. package/dist/diff/parse.js +303 -0
  32. package/dist/diff/parse.js.map +1 -0
  33. package/dist/diff/types.d.ts +106 -0
  34. package/dist/diff/types.d.ts.map +1 -0
  35. package/dist/diff/types.js +23 -0
  36. package/dist/diff/types.js.map +1 -0
  37. package/dist/embed.d.ts +5 -2
  38. package/dist/embed.d.ts.map +1 -1
  39. package/dist/embed.js +7 -3
  40. package/dist/embed.js.map +1 -1
  41. package/dist/enhance/prompt.d.ts +5 -1
  42. package/dist/enhance/prompt.d.ts.map +1 -1
  43. package/dist/enhance/prompt.js +9 -2
  44. package/dist/enhance/prompt.js.map +1 -1
  45. package/dist/format.d.ts +31 -0
  46. package/dist/format.d.ts.map +1 -1
  47. package/dist/format.js +256 -15
  48. package/dist/format.js.map +1 -1
  49. package/dist/index.d.ts +2 -1
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/memory/pageindex/index.d.ts +2 -2
  54. package/dist/memory/pageindex/index.d.ts.map +1 -1
  55. package/dist/memory/pageindex/index.js.map +1 -1
  56. package/dist/memory/pageindex/service.d.ts +10 -1
  57. package/dist/memory/pageindex/service.d.ts.map +1 -1
  58. package/dist/memory/pageindex/service.js +2 -2
  59. package/dist/memory/pageindex/service.js.map +1 -1
  60. package/dist/memory/persist.d.ts.map +1 -1
  61. package/dist/memory/persist.js +10 -3
  62. package/dist/memory/persist.js.map +1 -1
  63. package/dist/memory/privacy.d.ts.map +1 -1
  64. package/dist/memory/privacy.js +45 -6
  65. package/dist/memory/privacy.js.map +1 -1
  66. package/dist/memory/sqlite.d.ts +1 -13
  67. package/dist/memory/sqlite.d.ts.map +1 -1
  68. package/dist/memory/sqlite.js +45 -27
  69. package/dist/memory/sqlite.js.map +1 -1
  70. package/dist/memory/taxonomy.d.ts.map +1 -1
  71. package/dist/memory/taxonomy.js +6 -1
  72. package/dist/memory/taxonomy.js.map +1 -1
  73. package/dist/pipeline/degrade.d.ts +61 -0
  74. package/dist/pipeline/degrade.d.ts.map +1 -0
  75. package/dist/pipeline/degrade.js +58 -0
  76. package/dist/pipeline/degrade.js.map +1 -0
  77. package/dist/pipeline/enrich.d.ts +29 -0
  78. package/dist/pipeline/enrich.d.ts.map +1 -0
  79. package/dist/pipeline/enrich.js +271 -0
  80. package/dist/pipeline/enrich.js.map +1 -0
  81. package/dist/pipeline/execute.d.ts +22 -0
  82. package/dist/pipeline/execute.d.ts.map +1 -0
  83. package/dist/pipeline/execute.js +250 -0
  84. package/dist/pipeline/execute.js.map +1 -0
  85. package/dist/pipeline/finalize.d.ts +26 -0
  86. package/dist/pipeline/finalize.d.ts.map +1 -0
  87. package/dist/pipeline/finalize.js +52 -0
  88. package/dist/pipeline/finalize.js.map +1 -0
  89. package/dist/pipeline/gather-context.d.ts +25 -0
  90. package/dist/pipeline/gather-context.d.ts.map +1 -0
  91. package/dist/pipeline/gather-context.js +169 -0
  92. package/dist/pipeline/gather-context.js.map +1 -0
  93. package/dist/pipeline/gather-safe.d.ts +39 -0
  94. package/dist/pipeline/gather-safe.d.ts.map +1 -0
  95. package/dist/pipeline/gather-safe.js +127 -0
  96. package/dist/pipeline/gather-safe.js.map +1 -0
  97. package/dist/pipeline/prepare-graph.d.ts +54 -0
  98. package/dist/pipeline/prepare-graph.d.ts.map +1 -0
  99. package/dist/pipeline/prepare-graph.js +174 -0
  100. package/dist/pipeline/prepare-graph.js.map +1 -0
  101. package/dist/pipeline/prepare.d.ts +40 -0
  102. package/dist/pipeline/prepare.d.ts.map +1 -0
  103. package/dist/pipeline/prepare.js +233 -0
  104. package/dist/pipeline/prepare.js.map +1 -0
  105. package/dist/pipeline/providers.d.ts +54 -0
  106. package/dist/pipeline/providers.d.ts.map +1 -0
  107. package/dist/pipeline/providers.js +163 -0
  108. package/dist/pipeline/providers.js.map +1 -0
  109. package/dist/pipeline/results.d.ts +35 -0
  110. package/dist/pipeline/results.d.ts.map +1 -0
  111. package/dist/pipeline/results.js +122 -0
  112. package/dist/pipeline/results.js.map +1 -0
  113. package/dist/pipeline/state.d.ts +92 -0
  114. package/dist/pipeline/state.d.ts.map +1 -0
  115. package/dist/pipeline/state.js +13 -0
  116. package/dist/pipeline/state.js.map +1 -0
  117. package/dist/pipeline.d.ts +10 -9
  118. package/dist/pipeline.d.ts.map +1 -1
  119. package/dist/pipeline.js +36 -1213
  120. package/dist/pipeline.js.map +1 -1
  121. package/dist/providers/gateway.d.ts.map +1 -1
  122. package/dist/providers/gateway.js +8 -0
  123. package/dist/providers/gateway.js.map +1 -1
  124. package/dist/recursive/index.d.ts +1 -0
  125. package/dist/recursive/index.d.ts.map +1 -1
  126. package/dist/recursive/index.js +7 -3
  127. package/dist/recursive/index.js.map +1 -1
  128. package/dist/recursive/patch-extractor.d.ts +58 -6
  129. package/dist/recursive/patch-extractor.d.ts.map +1 -1
  130. package/dist/recursive/patch-extractor.js +207 -26
  131. package/dist/recursive/patch-extractor.js.map +1 -1
  132. package/dist/sanitize.d.ts +51 -0
  133. package/dist/sanitize.d.ts.map +1 -0
  134. package/dist/sanitize.js +90 -0
  135. package/dist/sanitize.js.map +1 -0
  136. package/dist/scope/diff-mapper.d.ts +12 -0
  137. package/dist/scope/diff-mapper.d.ts.map +1 -1
  138. package/dist/scope/diff-mapper.js +25 -18
  139. package/dist/scope/diff-mapper.js.map +1 -1
  140. package/dist/scope/entity-diff.d.ts +21 -4
  141. package/dist/scope/entity-diff.d.ts.map +1 -1
  142. package/dist/scope/entity-diff.js +132 -34
  143. package/dist/scope/entity-diff.js.map +1 -1
  144. package/dist/scope/types.d.ts +10 -0
  145. package/dist/scope/types.d.ts.map +1 -1
  146. package/dist/semantic-diff/index.d.ts +25 -2
  147. package/dist/semantic-diff/index.d.ts.map +1 -1
  148. package/dist/semantic-diff/index.js +147 -53
  149. package/dist/semantic-diff/index.js.map +1 -1
  150. package/dist/tools/gitleaks-config.toml +35 -0
  151. package/dist/tools/plugins/gitleaks.d.ts +10 -0
  152. package/dist/tools/plugins/gitleaks.d.ts.map +1 -1
  153. package/dist/tools/plugins/gitleaks.js +29 -2
  154. package/dist/tools/plugins/gitleaks.js.map +1 -1
  155. package/dist/tools/plugins/semgrep.d.ts +11 -0
  156. package/dist/tools/plugins/semgrep.d.ts.map +1 -1
  157. package/dist/tools/plugins/semgrep.js +30 -1
  158. package/dist/tools/plugins/semgrep.js.map +1 -1
  159. package/dist/tools/semgrep-rules.yml +305 -0
  160. package/dist/types.d.ts +51 -1
  161. package/dist/types.d.ts.map +1 -1
  162. package/dist/types.js.map +1 -1
  163. package/dist/utils/diff.d.ts +22 -2
  164. package/dist/utils/diff.d.ts.map +1 -1
  165. package/dist/utils/diff.js +36 -40
  166. package/dist/utils/diff.js.map +1 -1
  167. package/package.json +21 -22
  168. package/dist/providers/fallback.d.ts +0 -54
  169. package/dist/providers/fallback.d.ts.map +0 -1
  170. package/dist/providers/fallback.js +0 -102
  171. package/dist/providers/fallback.js.map +0 -1
  172. package/dist/providers/index.d.ts +0 -49
  173. package/dist/providers/index.d.ts.map +0 -1
  174. package/dist/providers/index.js +0 -146
  175. package/dist/providers/index.js.map +0 -1
package/dist/pipeline.js CHANGED
@@ -1,102 +1,25 @@
1
1
  /**
2
2
  * Main review pipeline orchestrator.
3
3
  *
4
- * Coordinates the entire review flow:
5
- * 1. Validate input
6
- * 2. Parse and filter the diff
7
- * 3. Detect tech stacks
8
- * 4. Run static analysis tools
9
- * 5. Search memory for past context
10
- * 6. Execute the selected agent mode
11
- * 7. Persist new observations to memory
12
- * 8. Return the final result
4
+ * Coordinates the entire review flow as a thin sequence of phases
5
+ * (each phase lives in `pipeline/` and shares a single mutable
6
+ * `PipelineState` see `pipeline/state.ts`):
7
+ *
8
+ * prepare → validate, parse/filter diff, flood check,
9
+ * blast-radius, call-chain, stacks, token budget
10
+ * gather-context static analysis memory ∥ code-intel + prompts
11
+ * execute → enhance compute, trust scoring, agent dispatch
12
+ * enrich → merge findings + post-processing (7 → 7.8)
13
+ * finalize → persist to memory + status downgrade
13
14
  *
14
15
  * Each step degrades gracefully — if static analysis fails, or
15
16
  * memory is unavailable, the pipeline continues with what it has.
16
17
  */
17
- import { runConsensusReview } from './agents/consensus.js';
18
- import { runDiagnosticReview } from './agents/diagnostic.js';
19
- import { loadLensesFromDir, runFanOutReview } from './agents/fan-out-lenses.js';
20
- import { buildCodeIntelSection, buildStackHints } from './agents/prompts.js';
21
- import { runSimpleReview } from './agents/simple.js';
22
- import { runWorkflowReview } from './agents/workflow.js';
23
- import { buildChecklistContext, resolveChecklistConfig, scoreFindings } from './checklist/index.js';
24
- import { buildCodeIntelContext } from './code-intel/context.js';
25
- import { extractChangedSymbols as extractChangedSymbolsFromDiff, scanDocsForSymbols as scanDocsForSymbolRefs, } from './doc-validation/index.js';
26
- import { enhanceFindings, mergeEnhanceResult } from './enhance/index.js';
27
- import { serializeFindings } from './enhance/prompt.js';
28
- import { analyzeExploitability, analyzeUsage } from './exploitability/index.js';
29
- import { detectFlood } from './flood/index.js';
30
- import { computeBlastRadius } from './graph/blast-radius.js';
31
- import { buildCallChainFromDiff } from './graph/call-chain.js';
32
- import { buildReverseDependencyMap, findDependents } from './graph/reverse-deps.js';
33
- import { isGraphStale } from './graph/schema.js';
34
- import { persistReviewObservations } from './memory/persist.js';
35
- import { searchMemoryForContext } from './memory/search.js';
36
- import { SqliteMemoryStorage } from './memory/sqlite.js';
37
- import { formatNegativeExamplesPrompt } from './negative.js';
38
- import { resolveCredentialEnvVar } from './providers/cli-bridge.js';
39
- import { createCLIBridgeGenerateFn, createGatewayGenerateFn, createOllamaGenerateFn, } from './providers/generate-fn.js';
40
- import { rankFindings } from './ranking/index.js';
41
- import { recursiveReview } from './recursive/index.js';
42
- import { deriveRules, formatRulesForPrompt, loadFeedback } from './self-improve/index.js';
43
- import { initializeDefaultTools } from './tools/plugins/index.js';
44
- import { toolRegistry } from './tools/registry.js';
45
- import { formatStaticAnalysisContext, isToolRegistryEnabled, runStaticAnalysis, } from './tools/runner.js';
46
- import { computeAuthorTrustScore, getReviewModeForTier } from './trust/index.js';
47
- import { buildProgressiveContext } from './utils/context-levels.js';
48
- import { filterDiffFiles, parseDiffFiles, truncateDiff } from './utils/diff.js';
49
- import { detectStacks } from './utils/stack-detect.js';
50
- import { calculateTokenBudget } from './utils/token-budget.js';
51
- // ─── Validation ─────────────────────────────────────────────────
52
- /**
53
- * Validate the review input for required fields.
54
- * Throws descriptive errors for misconfiguration.
55
- */
56
- function validateInput(input) {
57
- if (!input.diff || input.diff.trim().length === 0) {
58
- throw new Error('Review input must include a non-empty diff');
59
- }
60
- // If AI review is explicitly disabled, no provider/model/key needed
61
- if (input.aiReviewEnabled === false) {
62
- return;
63
- }
64
- // Provider chain mode: validate the chain has entries
65
- if (input.providerChain && input.providerChain.length > 0) {
66
- return;
67
- }
68
- // CLI Bridge mode: no API key required (uses CLI auth)
69
- if (input.provider === 'cli-bridge') {
70
- return;
71
- }
72
- // Gateway mode: uses dashboard-configured token (stored as apiKey in chain entry)
73
- if (input.provider === 'gateway') {
74
- return;
75
- }
76
- // Ollama mode: no API key required (local instance)
77
- if (input.provider === 'ollama') {
78
- return;
79
- }
80
- // Single provider mode — must be one of the 3 supported providers
81
- if (input.provider) {
82
- const supported = ['gateway', 'cli-bridge', 'ollama'];
83
- if (!supported.includes(input.provider)) {
84
- throw new Error(`Provider '${input.provider}' is no longer supported directly. ` +
85
- `Set provider: 'gateway' and configure credentials in mcp-llm-bridge. ` +
86
- `See docs/configuration.md#gateway-mode-mcp-llm-bridge`);
87
- }
88
- }
89
- if (!input.apiKey && input.provider !== 'cli-bridge' && input.provider !== 'ollama') {
90
- throw new Error('Review input must include an API key');
91
- }
92
- if (!input.provider) {
93
- throw new Error('Review input must specify an LLM provider');
94
- }
95
- if (!input.model) {
96
- throw new Error('Review input must specify a model');
97
- }
98
- }
99
- // ─── Pipeline ───────────────────────────────────────────────────
18
+ import { enrich } from './pipeline/enrich.js';
19
+ import { execute } from './pipeline/execute.js';
20
+ import { finalize } from './pipeline/finalize.js';
21
+ import { gatherContext } from './pipeline/gather-context.js';
22
+ import { prepare } from './pipeline/prepare.js';
100
23
  /**
101
24
  * Run the full review pipeline.
102
25
  *
@@ -109,1126 +32,26 @@ function validateInput(input) {
109
32
  * @returns ReviewResult with status, findings, and metadata
110
33
  */
111
34
  export async function reviewPipeline(input) {
112
- const startTime = Date.now();
113
- const emit = input.onProgress ?? (() => { });
114
- // Track steps that failed but were gracefully degraded
115
- const failedSteps = [];
116
- // Resolve whether AI review is enabled
117
- const aiEnabled = resolveAiEnabled(input);
118
- // ── Step 1: Validate ───────────────────────────────────────
119
- validateInput(input);
120
- emit({ step: 'validate', message: 'Input validated' });
121
- // ── Step 2: Parse and filter the diff ──────────────────────
122
- const allFiles = parseDiffFiles(input.diff);
123
- let { filtered: filteredFiles, blocked, redacted, } = filterDiffFiles(allFiles, input.settings.ignorePatterns);
124
- if (blocked.length > 0) {
125
- emit({
126
- step: 'path-protection',
127
- message: `Blocked ${blocked.length} sensitive file(s) from review`,
128
- detail: blocked.map((p) => ` [BLOCKED] ${p}`).join('\n'),
129
- });
130
- }
131
- if (redacted.length > 0) {
132
- emit({
133
- step: 'path-protection',
134
- message: `Redacted ${redacted.length} file(s) — paths visible, content hidden`,
135
- detail: redacted.map((p) => ` [REDACTED] ${p}`).join('\n'),
136
- });
137
- }
138
- emit({
139
- step: 'parse-diff',
140
- message: `Parsed ${allFiles.length} files from diff, ${filteredFiles.length} after filtering (${blocked.length} blocked, ${redacted.length} redacted)`,
141
- detail: filteredFiles.map((f) => ` ${f.path}`).join('\n'),
142
- });
143
- // ── Step 2.1: Flood / spam detection ──────────────────────
144
- // Runs before any expensive operation (static analysis, LLM).
145
- const linesChanged = allFiles.reduce((sum, f) => sum + f.additions + f.deletions, 0);
146
- const floodResult = detectFlood({
147
- authorLogin: input.author ?? '',
148
- prTitle: input.context?.commitMessages[0] ?? '',
149
- prBody: input.context?.commitMessages.slice(1).join('\n') ?? null,
150
- linesChanged,
151
- recentPrCount: undefined,
152
- });
153
- if (floodResult.isFlood) {
154
- emit({
155
- step: 'flood-detection',
156
- message: `Flood detected: ${floodResult.recommendation}`,
157
- detail: floodResult.signals.map((s) => ` [${s.type}] ${s.detail}`).join('\n'),
158
- });
159
- }
160
- if (floodResult.recommendation === 'skip') {
161
- const skipped = createSkippedResult(input, startTime);
162
- return {
163
- ...skipped,
164
- summary: 'Flood detection: PR skipped (bot author or spam signal).',
165
- };
166
- }
167
- // If all files were filtered out, skip the review
168
- if (filteredFiles.length === 0) {
169
- return createSkippedResult(input, startTime);
170
- }
171
- // Reconstruct filtered diff and get file list
172
- let filteredDiff = filteredFiles.map((f) => f.content).join('\n');
173
- const fileList = filteredFiles.map((f) => f.path);
174
- // ── Step 2.5: Blast-radius filter (optional) ──────────────
175
- let blastRadiusMetadata;
176
- if (input.settings.enableBlastRadius && input.graphLoader) {
177
- try {
178
- const graph = await input.graphLoader.load();
179
- if (graph) {
180
- const metadata = await input.graphLoader.loadMetadata();
181
- const stale = metadata ? isGraphStale(metadata) : false;
182
- if (stale) {
183
- emit({
184
- step: 'blast-radius',
185
- message: `Dependency graph is stale (last indexed: ${metadata?.lastIndexedAt})`,
186
- });
187
- }
188
- const blastResult = computeBlastRadius(graph, fileList, {
189
- maxDepth: input.settings.traversalDepth,
190
- maxFiles: input.settings.maxBlastRadiusFiles,
191
- });
192
- if (blastResult.exceededCap) {
193
- emit({
194
- step: 'blast-radius',
195
- message: `Blast radius exceeds ${input.settings.maxBlastRadiusFiles ?? 50} files — using full diff`,
196
- });
197
- blastRadiusMetadata = {
198
- enabled: true,
199
- graphAvailable: true,
200
- totalFiles: filteredFiles.length,
201
- blastRadiusFiles: filteredFiles.length,
202
- fallbackReason: `blast radius exceeds ${input.settings.maxBlastRadiusFiles ?? 50} files`,
203
- graphStale: stale,
204
- };
205
- }
206
- else {
207
- // Filter to blast-radius files
208
- filteredFiles = filteredFiles.filter((f) => blastResult.files.has(f.path));
209
- filteredDiff = filteredFiles.map((f) => f.content).join('\n');
210
- emit({
211
- step: 'blast-radius',
212
- message: `Blast radius: ${blastResult.files.size} files (from ${fileList.length} in diff)`,
213
- detail: [
214
- ` changed: ${blastResult.changedFiles.length}`,
215
- ` dependents: ${blastResult.dependents.length}`,
216
- ` tests: ${blastResult.testFiles.length}`,
217
- ].join('\n'),
218
- });
219
- blastRadiusMetadata = {
220
- enabled: true,
221
- graphAvailable: true,
222
- totalFiles: fileList.length,
223
- blastRadiusFiles: blastResult.files.size,
224
- graphStale: stale,
225
- };
226
- }
227
- }
228
- else {
229
- emit({ step: 'blast-radius', message: 'Blast radius: skipped (no graph available)' });
230
- blastRadiusMetadata = {
231
- enabled: true,
232
- graphAvailable: false,
233
- totalFiles: filteredFiles.length,
234
- blastRadiusFiles: filteredFiles.length,
235
- fallbackReason: 'no graph available',
236
- };
237
- }
238
- }
239
- catch (error) {
240
- console.warn('[ghagga] Blast-radius failed (degrading gracefully):', error);
241
- failedSteps.push({
242
- step: 'blast-radius',
243
- error: error instanceof Error ? error.message : String(error),
244
- });
245
- emit({ step: 'blast-radius', message: 'Blast radius: skipped (error loading graph)' });
246
- blastRadiusMetadata = {
247
- enabled: true,
248
- graphAvailable: false,
249
- totalFiles: filteredFiles.length,
250
- blastRadiusFiles: filteredFiles.length,
251
- fallbackReason: `error: ${error instanceof Error ? error.message : String(error)}`,
252
- };
253
- }
254
- }
255
- // ── Step 2.6: Call-chain + reverse-deps (optional, runs when blast-radius enabled) ──
256
- let callChainContext = '';
257
- if (input.settings.enableBlastRadius) {
258
- try {
259
- if (input.fileReader) {
260
- const fileContentsMap = new Map();
261
- for (const fp of fileList) {
262
- try {
263
- const content = await input.fileReader(fp);
264
- if (content)
265
- fileContentsMap.set(fp, content);
266
- }
267
- catch {
268
- // non-fatal — skip unreadable files
269
- }
270
- }
271
- if (fileContentsMap.size > 0) {
272
- const callChain = buildCallChainFromDiff(filteredDiff, fileContentsMap);
273
- if (callChain.affectedSymbols.length > 0) {
274
- const affectedFiles = [...new Set(callChain.affectedSymbols.map((s) => s.filePath))];
275
- callChainContext = `\n## Call-Chain Impact\n${callChain.affectedSymbols.length} symbol(s) across ${affectedFiles.length} file(s) may be affected by these changes (depth: ${callChain.depth}).\n`;
276
- emit({
277
- step: 'call-chain',
278
- message: `Call-chain: ${callChain.changedSymbols.length} changed symbol(s), ${callChain.affectedSymbols.length} affected symbol(s)`,
279
- });
280
- }
281
- const reverseDepMap = buildReverseDependencyMap([...fileContentsMap.keys()], fileContentsMap);
282
- const highRiskFiles = [];
283
- for (const fp of fileList) {
284
- const result = findDependents(fp, reverseDepMap, 2);
285
- if (result.transitiveCount >= 3) {
286
- highRiskFiles.push(`${fp} (${result.transitiveCount} dependents)`);
287
- }
288
- }
289
- if (highRiskFiles.length > 0) {
290
- callChainContext += `\n## High-Risk Files (many dependents)\nThese changed files have many transitive dependents — review carefully:\n${highRiskFiles.map((f) => `- ${f}`).join('\n')}\n`;
291
- emit({
292
- step: 'reverse-deps',
293
- message: `Reverse deps: ${highRiskFiles.length} high-risk file(s) detected`,
294
- });
295
- }
296
- }
297
- }
298
- }
299
- catch (error) {
300
- console.warn('[ghagga] Call-chain/reverse-deps failed (degrading gracefully):', error instanceof Error ? error.message : String(error));
301
- }
302
- }
303
- // ── Step 3: Detect tech stacks ─────────────────────────────
304
- const stacks = detectStacks(fileList);
305
- const stackHints = buildStackHints(stacks);
306
- emit({
307
- step: 'detect-stacks',
308
- message: `Detected ${stacks.length} tech stack(s)`,
309
- detail: stacks.length > 0 ? stacks.map((s) => ` ${s}`).join('\n') : ' (none detected)',
310
- });
311
- // ── Step 4: Truncate diff to fit token budget ──────────────
312
- const primaryModel = resolvePrimaryModel(input);
313
- const { diffBudget, contextBudget } = calculateTokenBudget(primaryModel);
314
- const { truncated: truncatedDiff } = truncateDiff(filteredDiff, diffBudget);
315
- emit({
316
- step: 'token-budget',
317
- message: `Token budget: ${diffBudget.toLocaleString()} tokens for diff, ${contextBudget.toLocaleString()} tokens for context`,
318
- });
319
- // ── Step 5: Run static analysis (in parallel with memory) ──
320
- // If precomputed results are available (from GitHub Actions runner), use those directly.
321
- // Otherwise, run tools locally (CLI/Action modes).
322
- emit({
323
- step: 'static-analysis',
324
- message: input.precomputedStaticAnalysis
325
- ? 'Using precomputed static analysis from runner...'
326
- : 'Running static analysis & memory search...',
327
- });
328
- const [staticResult, rawMemoryContext, codeIntelResults] = await Promise.all([
329
- input.precomputedStaticAnalysis
330
- ? Promise.resolve(input.precomputedStaticAnalysis)
331
- : runStaticAnalysisSafe(fileList, input, failedSteps),
332
- aiEnabled ? searchMemorySafe(input, fileList, failedSteps) : Promise.resolve(null),
333
- queryCodeIntelSafe(input, fileList, emit, failedSteps),
334
- ]);
335
- // ── Step 5.0: Negative examples (optional) ────────────────────
336
- // Load dismissed findings for the files in this diff and prepend them
337
- // to the memory context so agents suppress known false positives.
338
- let negativeExamplesPrompt = '';
339
- if (input.features?.negativeExamples !== false &&
340
- input.memoryStorage instanceof SqliteMemoryStorage) {
341
- try {
342
- const allNegativeExamples = fileList.flatMap((filePath) => input.memoryStorage.getNegativeExamplesForFile(filePath));
343
- // De-duplicate by findingHash
344
- const seen = new Set();
345
- const uniqueExamples = allNegativeExamples.filter((e) => {
346
- if (seen.has(e.findingHash))
347
- return false;
348
- seen.add(e.findingHash);
349
- return true;
350
- });
351
- negativeExamplesPrompt = formatNegativeExamplesPrompt(uniqueExamples);
352
- if (negativeExamplesPrompt) {
353
- emit({
354
- step: 'negative-examples',
355
- message: `Loaded ${uniqueExamples.length} dismissed finding(s) — injecting suppression context`,
356
- });
357
- }
358
- }
359
- catch (error) {
360
- // Non-fatal — degraded gracefully
361
- console.warn('[ghagga] Negative examples load failed (degrading gracefully):', error instanceof Error ? error.message : String(error));
362
- }
363
- }
364
- // ── Step 5.0a: Self-improve rules (optional) ─────────────────
365
- let selfImproveRulesPrompt = '';
366
- if (input.settings.selfImprovePath) {
367
- try {
368
- const feedback = await loadFeedback(input.settings.selfImprovePath);
369
- if (feedback.length > 0) {
370
- const rules = deriveRules(feedback);
371
- selfImproveRulesPrompt = formatRulesForPrompt(rules);
372
- if (selfImproveRulesPrompt) {
373
- emit({
374
- step: 'self-improve',
375
- message: `Self-improve: loaded ${feedback.length} feedback record(s), derived ${rules.length} rule(s)`,
376
- });
377
- }
378
- }
379
- }
380
- catch (error) {
381
- console.warn('[ghagga] Self-improve rules load failed (degrading gracefully):', error instanceof Error ? error.message : String(error));
382
- }
383
- }
384
- // Build full (L2) context first, then choose fidelity level based on budget
385
- const fullStaticContext = formatStaticAnalysisContext(staticResult);
386
- // Prepend self-improve rules + negative examples to memory context
387
- const rawMemoryContextWithNegatives = [selfImproveRulesPrompt, negativeExamplesPrompt, rawMemoryContext].filter(Boolean).join('\n') ||
388
- null;
389
- const progressiveContext = buildProgressiveContext({
390
- staticResult,
391
- memoryContext: rawMemoryContextWithNegatives,
392
- stackHints,
393
- contextBudget,
394
- fullStaticContext,
395
- });
396
- const staticContext = progressiveContext.staticContext;
397
- const memoryContext = progressiveContext.memoryContext;
398
- // ── Step 5.1b: Build code intelligence context (optional) ───
399
- const codeIntelContext = codeIntelResults.length > 0
400
- ? buildCodeIntelSection(buildCodeIntelContext(codeIntelResults, input.settings.codeIntelMaxTokens))
401
- : '';
402
- let codeIntelMetadata;
403
- if (input.settings.enableCodeIntel) {
404
- codeIntelMetadata = {
405
- enabled: true,
406
- providerAvailable: !!input.codeIntelProvider,
407
- filesQueried: fileList.length,
408
- filesWithData: codeIntelResults.filter((r) => r.callers.length > 0 || r.callees.length > 0 || r.imports.length > 0).length,
409
- queryDurationMs: 0, // Timing captured in queryCodeIntelSafe
410
- };
411
- }
412
- {
413
- const toolsSummary = Object.entries(staticResult)
414
- .map(([name, result]) => ` ${name}: ${result.status} (${result.findings.length} findings)`)
415
- .join('\n');
416
- const levelDetail = ` context levels: static=${progressiveContext.staticLevel}, memory=${progressiveContext.memoryLevel}`;
417
- const codeIntelDetail = codeIntelContext
418
- ? `\n code-intel: ${codeIntelResults.length} file(s) with structural data`
419
- : '\n code-intel: disabled or unavailable';
420
- emit({
421
- step: 'static-results',
422
- message: `Static analysis complete (context: static=${progressiveContext.staticLevel}, memory=${progressiveContext.memoryLevel})`,
423
- detail: toolsSummary +
424
- (rawMemoryContext ? '\n memory: loaded' : '\n memory: disabled') +
425
- codeIntelDetail +
426
- '\n' +
427
- levelDetail,
428
- });
429
- }
430
- // ── Step 5.4: Build checklist context (optional) ────────────
431
- let checklistContext = '';
432
- const resolvedChecklist = resolveChecklistConfig(input.settings.checklist);
433
- if (resolvedChecklist) {
434
- checklistContext = buildChecklistContext(resolvedChecklist);
435
- if (checklistContext) {
436
- emit({
437
- step: 'checklist',
438
- message: `Checklist active: ${resolvedChecklist.dimensions.filter((d) => d.enabled).length} dimensions`,
439
- });
440
- }
441
- }
442
- // ── Step 5.5: AI Enhance (optional) ─────────────────────────
443
- // Resolve active provider early — needed by enhance block below and by Step 6
444
- const activeProvider = input.providerChain?.[0]?.provider ?? input.provider ?? 'gateway';
445
- const isCliBridge = activeProvider === 'cli-bridge';
446
- const isGateway = activeProvider === 'gateway';
447
- const isOllama = activeProvider === 'ollama';
448
- let enhancedStaticFindings;
449
- let enhanceMetadata;
450
- if (input.enhance) {
451
- // Collect all static findings
452
- const allStaticFindings = [];
453
- for (const toolResult of Object.values(staticResult)) {
454
- allStaticFindings.push(...toolResult.findings);
455
- }
456
- if (allStaticFindings.length > 0) {
457
- emit({ step: 'static-analysis', message: 'Enhancing findings with AI...' });
458
- try {
459
- const primary = resolvePrimaryProvider(input);
460
- const enhanceGenerateFn = resolveGenerateTextFns(input, isCliBridge, isGateway, isOllama)[0];
461
- const serialized = serializeFindings(allStaticFindings);
462
- const { result: eResult, metadata: eMeta } = await enhanceFindings({
463
- findings: serialized,
464
- provider: primary.provider,
465
- model: primary.model,
466
- apiKey: primary.apiKey,
467
- generateFn: enhanceGenerateFn,
468
- });
469
- enhancedStaticFindings = mergeEnhanceResult(allStaticFindings, eResult);
470
- enhanceMetadata = eMeta;
471
- }
472
- catch (enhanceError) {
473
- failedSteps.push({
474
- step: 'ai-enhance',
475
- error: enhanceError instanceof Error ? enhanceError.message : String(enhanceError),
476
- });
477
- emit({
478
- step: 'static-analysis',
479
- message: 'AI enhance failed — continuing without enhancement',
480
- });
481
- }
482
- }
483
- }
484
- // ── Step 5.6: Author trust scoring (optional) ──────────────
485
- // When features.authorTrust is enabled and input.author is set, compute a
486
- // trust score from git history and potentially override the review mode.
487
- let trustOverrideMode;
488
- if (input.features?.authorTrust && input.author) {
489
- try {
490
- const author = input.author;
491
- const sqliteStorage = input.memoryStorage instanceof SqliteMemoryStorage ? input.memoryStorage : null;
492
- // Check for a cached (fresh) score — recompute if older than 1 day
493
- const ONE_DAY_MS = 24 * 60 * 60 * 1000;
494
- let trustScore = sqliteStorage?.getTrustScore(author) ?? null;
495
- const isStale = !trustScore || Date.now() - trustScore.lastUpdated.getTime() > ONE_DAY_MS;
496
- if (isStale) {
497
- trustScore = await computeAuthorTrustScore(author, { cwd: process.cwd() });
498
- sqliteStorage?.upsertTrustScore(trustScore);
499
- }
500
- if (!trustScore) {
501
- throw new Error('Trust score unavailable');
502
- }
503
- const recommendedMode = getReviewModeForTier(trustScore.tier, input.mode);
504
- if (recommendedMode !== input.mode) {
505
- trustOverrideMode = recommendedMode;
506
- }
507
- emit({
508
- step: 'author-trust',
509
- message: `[trust] author=${author} score=${trustScore.score} tier=${trustScore.tier} → mode=${recommendedMode}`,
510
- });
511
- }
512
- catch (error) {
513
- console.warn('[ghagga] Author trust scoring failed (non-fatal):', error instanceof Error ? error.message : String(error));
514
- failedSteps.push({
515
- step: 'author-trust',
516
- error: error instanceof Error ? error.message : String(error),
517
- });
518
- }
519
- }
520
- // Effective input mode — may be overridden by trust scoring
521
- const resolvedInputMode = trustOverrideMode ?? input.mode;
522
- // ── Step 6: Execute agent mode (or skip if AI disabled) ────
523
- let result;
524
- // activeProvider / isCliBridge / isGateway / isOllama are resolved above (Step 5.5)
525
- if (!aiEnabled) {
526
- // Static-only mode: no LLM calls
527
- emit({ step: 'agent-start', message: 'AI review disabled — returning static analysis only' });
528
- result = createStaticOnlyResult(staticResult, resolvedInputMode, startTime);
529
- }
530
- else {
531
- // ── Unified dispatch: all backends, all modes ──────────────
532
- // Step 1: Build GenerateTextFn(s) for the detected backend
533
- const generateFns = resolveGenerateTextFns(input, isCliBridge, isGateway, isOllama);
534
- // Step 2: Resolve effective mode (diagnostic → simple for non-SDK backends)
535
- const effectiveMode = resolveEffectiveMode(resolvedInputMode, isCliBridge, isGateway, isOllama);
536
- if (effectiveMode !== resolvedInputMode) {
537
- emit({
538
- step: 'mode-fallback',
539
- message: `Diagnostic mode not supported with ${isCliBridge ? 'CLI bridge' : isOllama ? 'Ollama' : 'gateway'} — falling back to simple mode`,
540
- });
541
- }
542
- // Resolve primary provider for progress messages and metadata
543
- const primary = resolvePrimaryProvider(input);
544
- emit({
545
- step: 'agent-start',
546
- message: `Running ${effectiveMode} agent with ${primary.provider}/${primary.model}...`,
547
- });
548
- // Combine stack hints, code intelligence context, and call-chain context for agent prompts
549
- const combinedStackHints = stackHints + codeIntelContext + callChainContext;
550
- try {
551
- switch (effectiveMode) {
552
- case 'simple':
553
- result = await runSimpleReview({
554
- diff: truncatedDiff,
555
- staticContext,
556
- memoryContext,
557
- stackHints: combinedStackHints,
558
- checklistContext,
559
- reviewLevel: input.settings.reviewLevel,
560
- onProgress: input.onProgress,
561
- generateFn: generateFns[0],
562
- // Backward compat fields (not used when generateFn is provided)
563
- provider: primary.provider ?? 'cli-bridge',
564
- model: primary.model ?? 'auto',
565
- apiKey: primary.apiKey ?? '',
566
- });
567
- break;
568
- case 'workflow':
569
- result = await runWorkflowReview({
570
- diff: truncatedDiff,
571
- staticContext,
572
- memoryContext,
573
- stackHints: combinedStackHints,
574
- checklistContext,
575
- reviewLevel: input.settings.reviewLevel,
576
- onProgress: input.onProgress,
577
- generateFns,
578
- concurrency: input.settings?.reviewConcurrency,
579
- delayMs: input.settings?.reviewDelayMs,
580
- // Backward compat fields (not used when generateFns is provided)
581
- provider: primary.provider ?? 'cli-bridge',
582
- model: primary.model ?? 'auto',
583
- apiKey: primary.apiKey ?? '',
584
- providerChain: input.providerChain,
585
- });
586
- break;
587
- case 'consensus':
588
- result = await runConsensusReview({
589
- diff: truncatedDiff,
590
- models: buildConsensusModels(input.providerChain, primary),
591
- staticContext,
592
- memoryContext,
593
- stackHints: combinedStackHints,
594
- checklistContext,
595
- reviewLevel: input.settings.reviewLevel,
596
- onProgress: input.onProgress,
597
- generateFns,
598
- concurrency: input.settings?.reviewConcurrency,
599
- delayMs: input.settings?.reviewDelayMs,
600
- });
601
- break;
602
- case 'diagnostic':
603
- // Diagnostic mode is AI SDK-only (resolveEffectiveMode ensures this)
604
- result = await runDiagnosticReview({
605
- diff: truncatedDiff,
606
- provider: primary.provider,
607
- model: primary.model,
608
- apiKey: primary.apiKey,
609
- staticContext,
610
- memoryContext,
611
- stackHints: combinedStackHints,
612
- checklistContext,
613
- reviewLevel: input.settings.reviewLevel,
614
- onProgress: input.onProgress,
615
- });
616
- break;
617
- case 'fan-out':
618
- // Load custom lenses from directory (if configured)
619
- if (input.settings.lensDir) {
620
- await loadLensesFromDir(input.settings.lensDir, input.onProgress);
621
- }
622
- result = await runFanOutReview({
623
- diff: truncatedDiff,
624
- provider: primary.provider ?? 'cli-bridge',
625
- model: primary.model ?? 'auto',
626
- apiKey: primary.apiKey ?? '',
627
- staticContext,
628
- memoryContext,
629
- stackHints: combinedStackHints,
630
- checklistContext,
631
- reviewLevel: input.settings.reviewLevel,
632
- onProgress: input.onProgress,
633
- generateFns,
634
- // Forward lens selection from settings (CLI flags > config > defaults)
635
- ...(input.settings.lenses ? { lenses: input.settings.lenses } : {}),
636
- });
637
- break;
638
- default: {
639
- const _exhaustive = effectiveMode;
640
- throw new Error(`Unknown review mode: ${_exhaustive}`);
641
- }
642
- }
643
- }
644
- catch (error) {
645
- // Agent failed — return static results with NEEDS_HUMAN_REVIEW
646
- console.warn('[ghagga] AI review failed, returning static analysis only:', error instanceof Error ? error.message : String(error));
647
- failedSteps.push({
648
- step: 'ai-review',
649
- error: error instanceof Error ? error.message : String(error),
650
- });
651
- emit({ step: 'agent-failed', message: 'AI review failed — returning static analysis only' });
652
- result = createStaticOnlyResult(staticResult, resolvedInputMode, startTime);
653
- result.status = 'NEEDS_HUMAN_REVIEW';
654
- result.summary = `AI review failed (${error instanceof Error ? error.message : 'unknown error'}). Static analysis results are shown below.`;
655
- }
656
- }
657
- // ── Step 7: Merge static analysis into result ──────────────
658
- result.staticAnalysis = staticResult;
659
- result.memoryContext = memoryContext;
660
- // Add static analysis findings to the result's findings array (dynamic — all tools)
661
- const staticFindings = Object.values(staticResult).flatMap((toolResult) => toolResult && typeof toolResult === 'object' && 'findings' in toolResult
662
- ? toolResult.findings
663
- : []);
664
- result.findings = [...result.findings, ...staticFindings];
665
- // ── Merge enhanced static findings into result ──────────────
666
- if (enhancedStaticFindings && enhanceMetadata) {
667
- result.enhanced = true;
668
- result.enhanceMetadata = enhanceMetadata;
669
- // Replace static-sourced findings with enhanced versions
670
- const nonStaticFindings = result.findings.filter((f) => f.source === 'ai');
671
- result.findings = [...enhancedStaticFindings, ...nonStaticFindings];
672
- }
673
- // Track which tools ran successfully
674
- result.metadata.toolsRun = [];
675
- result.metadata.toolsSkipped = [];
676
- for (const [name, tool] of Object.entries(staticResult)) {
677
- if (tool.status === 'success') {
678
- result.metadata.toolsRun.push(name);
679
- }
680
- else {
681
- result.metadata.toolsSkipped.push(name);
682
- }
683
- }
684
- // Update execution time to cover the full pipeline
685
- result.metadata.executionTimeMs = Date.now() - startTime;
686
- // Add file stats metadata (for emoji stats bar in comment)
687
- result.metadata.totalAdditions = allFiles.reduce((sum, f) => sum + f.additions, 0);
688
- result.metadata.totalDeletions = allFiles.reduce((sum, f) => sum + f.deletions, 0);
689
- result.metadata.fileList = allFiles.map((f) => f.path);
690
- // Add blast-radius metadata (if applicable)
691
- if (blastRadiusMetadata) {
692
- result.metadata.blastRadius = blastRadiusMetadata;
693
- }
694
- // Add code intelligence metadata (if applicable)
695
- if (codeIntelMetadata) {
696
- result.codeIntelMetadata = codeIntelMetadata;
697
- }
698
- // ── Step 7.4: Exploitability analysis (optional) ────────────
699
- if (input.settings.enableBlastRadius && result.findings.length > 0) {
700
- const trivyCveCount = result.findings.filter((f) => f.source === 'trivy' && f.category === 'dependency-vulnerability').length;
701
- if (trivyCveCount > 0) {
702
- emit({
703
- step: 'exploitability',
704
- message: `Analyzing exploitability for ${trivyCveCount} CVE(s)...`,
705
- });
706
- try {
707
- // Load graph if not already loaded (reuse from blast-radius when available)
708
- const exploitGraph = input.graphLoader ? await input.graphLoader.load() : null;
709
- analyzeExploitability(result.findings, exploitGraph);
710
- const labels = result.findings
711
- .filter((f) => f.exploitability)
712
- .reduce((acc, f) => {
713
- const key = f.exploitability ?? 'unknown';
714
- acc[key] = (acc[key] ?? 0) + 1;
715
- return acc;
716
- }, {});
717
- const exploitable = labels.exploitable ?? 0;
718
- const potential = labels['potentially-exploitable'] ?? 0;
719
- const notExploitable = labels['not-exploitable'] ?? 0;
720
- emit({
721
- step: 'exploitability',
722
- message: `Exploitability analysis complete: ${exploitable} exploitable, ${potential} potentially, ${notExploitable} not exploitable`,
723
- });
724
- // Function-level usage analysis (requires fileReader)
725
- if (input.fileReader) {
726
- emit({
727
- step: 'usage-analysis',
728
- message: 'Analyzing function-level usage of vulnerable packages...',
729
- });
730
- await analyzeUsage(result.findings, exploitGraph, input.fileReader);
731
- const usageLabels = result.findings
732
- .filter((f) => f.usageLabel)
733
- .reduce((acc, f) => {
734
- const key = f.usageLabel ?? 'unknown';
735
- acc[key] = (acc[key] ?? 0) + 1;
736
- return acc;
737
- }, {});
738
- const inUse = usageLabels['in-use'] ?? 0;
739
- const importedNotCalled = usageLabels['imported-not-called'] ?? 0;
740
- const notInUse = usageLabels['not-in-use'] ?? 0;
741
- emit({
742
- step: 'usage-analysis',
743
- message: `Usage analysis complete: ${inUse} in-use, ${importedNotCalled} imported-not-called, ${notInUse} not-in-use`,
744
- });
745
- }
746
- }
747
- catch (error) {
748
- console.warn('[ghagga] Exploitability analysis failed (non-fatal):', error instanceof Error ? error.message : String(error));
749
- failedSteps.push({
750
- step: 'exploitability',
751
- error: error instanceof Error ? error.message : String(error),
752
- });
753
- emit({
754
- step: 'exploitability',
755
- message: 'Exploitability analysis failed — continuing without',
756
- });
757
- }
758
- }
759
- }
760
- // ── Step 7.5: Score findings against checklist (optional) ───
761
- if (resolvedChecklist && result.findings.length > 0) {
762
- result.checklistScore = scoreFindings(result.findings, resolvedChecklist);
763
- emit({
764
- step: 'checklist-score',
765
- message: `Checklist score: ${result.checklistScore.totalScore} (${result.checklistScore.findings.length} matched findings)`,
766
- });
767
- }
768
- // ── Step 7.6: Recursive review (optional) ──────────────────────
769
- if (input.settings.enableRecursiveReview && aiEnabled && result.findings.length > 0) {
770
- emit({ step: 'recursive-review', message: 'Running recursive review on suggested fixes...' });
771
- try {
772
- const generateFns = resolveGenerateTextFns(input, isCliBridge, isGateway, isOllama);
773
- const report = await recursiveReview({
774
- originalDiff: truncatedDiff,
775
- findings: result.findings,
776
- generateFn: generateFns[0],
777
- config: {
778
- maxIterations: input.settings.maxRecursiveIterations ?? 2,
779
- },
780
- onProgress: (message) => emit({ step: 'recursive-review', message }),
781
- });
782
- if (report) {
783
- result.recursiveReview = report;
784
- // Add regressions to the findings array
785
- if (report.regressions.length > 0) {
786
- result.findings = [...result.findings, ...report.regressions];
787
- emit({
788
- step: 'recursive-review',
789
- message: `Recursive review: ${report.regressions.length} regression(s) found in suggested fixes`,
790
- });
791
- }
792
- else {
793
- emit({
794
- step: 'recursive-review',
795
- message: `Recursive review: suggestions validated — ${report.converged ? 'converged' : 'no regressions'} after ${report.iterations} iteration(s)`,
796
- });
797
- }
798
- }
799
- }
800
- catch (error) {
801
- console.warn('[ghagga] Recursive review failed (non-fatal):', error instanceof Error ? error.message : String(error));
802
- failedSteps.push({
803
- step: 'recursive-review',
804
- error: error instanceof Error ? error.message : String(error),
805
- });
806
- emit({ step: 'recursive-review', message: 'Recursive review failed — continuing without' });
807
- }
808
- }
809
- // ── Step 7.7: Code-doc validation (optional) ───────────────────
810
- if (input.settings.enableDocValidation && filteredFiles.length > 0) {
811
- try {
812
- const changedSymbols = extractChangedSymbolsFromDiff(filteredDiff);
813
- if (changedSymbols.length > 0) {
814
- emit({
815
- step: 'doc-validation',
816
- message: `Scanning docs for ${changedSymbols.length} changed symbol(s)...`,
817
- });
818
- const docResult = scanDocsForSymbolRefs(changedSymbols, allFiles, fileList);
819
- result.docValidation = docResult;
820
- if (docResult.staleReferences.length > 0) {
821
- // Convert stale references to findings
822
- for (const ref of docResult.staleReferences) {
823
- result.findings.push({
824
- severity: 'low',
825
- category: 'documentation',
826
- file: ref.file,
827
- line: ref.line,
828
- message: `Documentation references \`${ref.symbol}\` which was changed in this PR but this doc was not updated.`,
829
- suggestion: `Review and update the reference to \`${ref.symbol}\` in this file.`,
830
- source: 'doc-validation',
831
- });
832
- }
833
- emit({
834
- step: 'doc-validation',
835
- message: `Doc validation: ${docResult.staleReferences.length} stale reference(s) found in ${docResult.docsScanned} doc(s)`,
836
- });
837
- }
838
- else {
839
- emit({
840
- step: 'doc-validation',
841
- message: `Doc validation: no stale references (${docResult.docsScanned} docs scanned)`,
842
- });
843
- }
844
- }
845
- }
846
- catch (error) {
847
- console.warn('[ghagga] Doc validation failed (non-fatal):', error instanceof Error ? error.message : String(error));
848
- failedSteps.push({
849
- step: 'doc-validation',
850
- error: error instanceof Error ? error.message : String(error),
851
- });
852
- emit({ step: 'doc-validation', message: 'Doc validation failed — continuing without' });
853
- }
854
- }
855
- // ── Step 7.8: Semantic ranking of findings (optional) ─────────
856
- const semanticRankingEnabled = input.features?.semanticRanking !== false && !!input.embeddingProvider;
857
- if (semanticRankingEnabled && result.findings.length > 1) {
858
- emit({ step: 'semantic-ranking', message: 'Reranking findings by semantic relevance...' });
859
- try {
860
- result.findings = await rankFindings(result.findings, input.embeddingProvider);
861
- emit({
862
- step: 'semantic-ranking',
863
- message: `Semantic ranking complete (${result.findings.length} findings reranked)`,
864
- });
865
- }
866
- catch (error) {
867
- console.warn('[ghagga] Semantic ranking failed (non-fatal):', error instanceof Error ? error.message : String(error));
868
- failedSteps.push({
869
- step: 'semantic-ranking',
870
- error: error instanceof Error ? error.message : String(error),
871
- });
872
- emit({ step: 'semantic-ranking', message: 'Semantic ranking failed — continuing without' });
873
- }
874
- }
875
- // ── Step 8: Persist to memory (awaited for SQLite correctness) ──
876
- if (input.settings.enableMemory && input.memoryStorage && input.context) {
877
- await persistReviewObservations(input.memoryStorage, input.context.repoFullName, input.context.prNumber, result).catch((error) => {
878
- console.warn('[ghagga] Memory persist failed (non-fatal):', error instanceof Error ? error.message : String(error));
879
- failedSteps.push({
880
- step: 'memory-persist',
881
- error: error instanceof Error ? error.message : String(error),
882
- });
883
- });
884
- }
885
- // ── Step 9: Attach failed steps and mark as PARTIAL ─────────
886
- if (failedSteps.length > 0) {
887
- result.failedSteps = failedSteps;
888
- // Only downgrade to PARTIAL if the review otherwise appeared successful
889
- if (result.status === 'PASSED') {
890
- result.status = 'PARTIAL';
891
- }
892
- }
893
- return result;
894
- }
895
- // ─── Provider Resolution ────────────────────────────────────────
896
- /**
897
- * Determine if AI review is enabled.
898
- * Defaults to true for backward compatibility (CLI/Action don't set this).
899
- */
900
- function resolveAiEnabled(input) {
901
- if (input.aiReviewEnabled === false)
902
- return false;
903
- // If chain is explicitly empty and no single provider, treat as disabled
904
- if (input.providerChain && input.providerChain.length === 0 && !input.provider) {
905
- console.warn('[ghagga] AI review enabled but provider chain is empty and no single provider — treating as disabled');
906
- return false;
907
- }
908
- return true;
909
- }
910
- /**
911
- * Resolve the primary provider from chain or flat fields.
912
- * Returns the first entry in the chain, or builds one from flat fields.
913
- */
914
- function resolvePrimaryProvider(input) {
915
- if (input.providerChain && input.providerChain.length > 0) {
916
- const first = input.providerChain[0];
917
- if (first)
918
- return first;
919
- }
920
- // Backward compat: single provider from flat fields
921
- if (!input.provider || !input.model || !input.apiKey) {
922
- throw new Error('No provider chain and no single provider configured');
923
- }
924
- return {
925
- provider: input.provider,
926
- model: input.model,
927
- apiKey: input.apiKey,
928
- };
929
- }
930
- /**
931
- * Build the 3-entry ConsensusModelConfig array for the for/against/neutral votes.
932
- *
933
- * Distribution rules (given a chain of length N):
934
- * N >= 3 : chain[0]→for, chain[1]→against, chain[2]→neutral
935
- * N == 2 : chain[0]→for, chain[1]→against, chain[0]→neutral
936
- * N == 1 : all 3 votes use chain[0] (same as primary-only)
937
- * N == 0 : all 3 votes use `primary` (backward compat)
938
- *
939
- * This spreads consensus votes across providers so each vote hits a
940
- * different TPM budget instead of all three hammering the same limit.
941
- */
942
- function buildConsensusModels(chain, primary) {
943
- const stances = ['for', 'against', 'neutral'];
944
- return stances.map((stance, i) => {
945
- const entry = chain && chain.length > 0 ? chain[i % chain.length] : primary;
946
- return {
947
- provider: entry.provider,
948
- model: entry.model,
949
- apiKey: entry.apiKey,
950
- stance,
951
- };
952
- });
953
- }
954
- /**
955
- * Resolve the model name for token budget calculation.
956
- */
957
- function resolvePrimaryModel(input) {
958
- if (input.providerChain && input.providerChain.length > 0) {
959
- return input.providerChain[0]?.model ?? 'gpt-4o-mini';
960
- }
961
- return input.model ?? 'gpt-4o-mini';
962
- }
963
- // ─── GenerateTextFn Resolution ──────────────────────────────────
964
- /**
965
- * Create the appropriate GenerateTextFn(s) based on the provider type.
966
- *
967
- * - cli-bridge: single fn wrapping generateViaCLI
968
- * - gateway: one fn per gateway chain entry (for round-robin distribution)
969
- * - ollama: single fn wrapping local Ollama OpenAI-compatible API
970
- *
971
- * Providers that are no longer supported directly (anthropic, openai, etc.)
972
- * throw a migration error pointing users to gateway mode.
973
- */
974
- function resolveGenerateTextFns(input, isCliBridge, isGateway, isOllama) {
975
- if (isCliBridge) {
976
- // Resolve CLI bridge options from provider chain or flat input fields
977
- const cliBridgeEntry = input.providerChain?.[0];
978
- const preferredCLI = (cliBridgeEntry?.model ?? input.model) !== 'auto'
979
- ? (cliBridgeEntry?.model ?? input.model)
980
- : undefined;
981
- const cliModel = cliBridgeEntry?.cliModel;
982
- // Build credentials from the decrypted API key
983
- const decryptedKey = cliBridgeEntry?.apiKey || input.apiKey;
984
- const credentialEnvName = resolveCredentialEnvVar(preferredCLI, cliModel);
985
- const credentials = {};
986
- if (preferredCLI && credentialEnvName && decryptedKey) {
987
- credentials[credentialEnvName] = decryptedKey;
988
- }
989
- return [
990
- createCLIBridgeGenerateFn({
991
- preferredCLI,
992
- cliModel,
993
- credentials: Object.keys(credentials).length > 0 ? credentials : undefined,
994
- }),
995
- ];
996
- }
997
- if (isGateway) {
998
- // Map ALL gateway entries in the chain — one GenerateTextFn per model
999
- // for round-robin distribution in workflow/consensus modes
1000
- const chain = input.providerChain?.filter((e) => e.provider === 'gateway') ?? [];
1001
- if (chain.length > 0) {
1002
- // Use gatewayUrl and token from the first entry (shared across all)
1003
- const gatewayUrl = chain[0]?.gatewayUrl ?? '';
1004
- const gatewayToken = chain[0]?.apiKey || input.apiKey || '';
1005
- return chain.map((entry) => {
1006
- const model = entry.model !== 'auto' ? entry.model : undefined;
1007
- return createGatewayGenerateFn({
1008
- gatewayUrl,
1009
- gatewayToken,
1010
- model,
1011
- project: 'ghagga',
1012
- });
1013
- });
1014
- }
1015
- // Fallback: single entry from flat input fields
1016
- return [
1017
- createGatewayGenerateFn({
1018
- gatewayUrl: '',
1019
- gatewayToken: input.apiKey || '',
1020
- model: input.model !== 'auto' ? input.model : undefined,
1021
- project: 'ghagga',
1022
- }),
1023
- ];
1024
- }
1025
- if (isOllama) {
1026
- const model = input.model && input.model !== 'auto' ? input.model : 'llama3';
1027
- return [createOllamaGenerateFn(model, input.ollamaBaseURL)];
1028
- }
1029
- // Legacy provider migration guard — should never reach here with the narrowed type,
1030
- // but protects against runtime strings from older configs.
1031
- const legacyProvider = input.providerChain?.[0]?.provider ?? input.provider ?? 'unknown';
1032
- throw new Error(`Provider '${legacyProvider}' is no longer supported directly. ` +
1033
- `Set provider: 'gateway' and configure credentials in mcp-llm-bridge. ` +
1034
- `See docs/configuration.md#gateway-mode-mcp-llm-bridge`);
1035
- }
1036
- /**
1037
- * Resolve the effective review mode.
1038
- *
1039
- * Diagnostic mode requires direct model access (not available in non-SDK backends).
1040
- * For CLI bridge, gateway, and Ollama, fall back to simple mode.
1041
- */
1042
- function resolveEffectiveMode(mode, isCliBridge, isGateway, isOllama) {
1043
- if (mode === 'diagnostic' && (isCliBridge || isGateway || isOllama)) {
1044
- return 'simple';
1045
- }
1046
- // Fan-out works with all backends (uses generateFns like workflow/consensus)
1047
- return mode;
1048
- }
1049
- // ─── Helpers ────────────────────────────────────────────────────
1050
- /**
1051
- * Run static analysis with graceful degradation.
1052
- * Returns a result with all tools skipped if anything goes wrong.
1053
- */
1054
- async function runStaticAnalysisSafe(fileList, input, failedSteps) {
1055
- try {
1056
- // Build a file map for static analysis (paths only, content from diff)
1057
- const files = new Map();
1058
- for (const path of fileList) {
1059
- files.set(path, ''); // Content is extracted from diff by the tool runner
1060
- }
1061
- return await runStaticAnalysis(files, '.', {
1062
- enableSemgrep: input.settings.enableSemgrep,
1063
- enableTrivy: input.settings.enableTrivy,
1064
- enableCpd: input.settings.enableCpd,
1065
- customRules: input.settings.customRules,
1066
- enabledTools: input.settings.enabledTools,
1067
- disabledTools: input.settings.disabledTools,
1068
- });
1069
- }
1070
- catch (error) {
1071
- console.warn('[ghagga] Static analysis failed (degrading gracefully):', error instanceof Error ? error.message : String(error));
1072
- failedSteps.push({
1073
- step: 'static-analysis',
1074
- error: error instanceof Error ? error.message : String(error),
1075
- });
1076
- const errorResult = {
1077
- status: 'error',
1078
- findings: [],
1079
- error: error instanceof Error ? error.message : String(error),
1080
- executionTimeMs: 0,
1081
- };
1082
- return {
1083
- semgrep: errorResult,
1084
- trivy: errorResult,
1085
- cpd: errorResult,
1086
- };
1087
- }
1088
- }
1089
- /**
1090
- * Search memory with graceful degradation.
1091
- * Returns null if memory is disabled or unavailable.
1092
- */
1093
- async function searchMemorySafe(input, fileList, failedSteps) {
1094
- if (!input.settings.enableMemory || !input.memoryStorage || !input.context) {
1095
- return null;
1096
- }
1097
- try {
1098
- return await searchMemoryForContext(input.memoryStorage, input.context.repoFullName, fileList);
1099
- }
1100
- catch (error) {
1101
- console.warn('[ghagga] Memory search failed (degrading gracefully):', error instanceof Error ? error.message : String(error));
1102
- failedSteps.push({
1103
- step: 'memory-search',
1104
- error: error instanceof Error ? error.message : String(error),
1105
- });
1106
- return null;
1107
- }
1108
- }
1109
- /**
1110
- * Query the code intelligence provider for structural context.
1111
- * Returns an empty array when disabled, unavailable, or on error.
1112
- */
1113
- async function queryCodeIntelSafe(input, fileList, emit, failedSteps) {
1114
- if (!input.settings.enableCodeIntel || !input.codeIntelProvider) {
1115
- return [];
1116
- }
1117
- const startTime = Date.now();
1118
- emit({
1119
- step: 'code-intel',
1120
- message: `Querying code intelligence for ${fileList.length} file(s)...`,
1121
- });
1122
- try {
1123
- const results = [];
1124
- const provider = input.codeIntelProvider;
1125
- // Query each changed file for structural data (parallel)
1126
- const queries = fileList.map(async (file) => {
1127
- const [imports, exports] = await Promise.all([
1128
- provider.getFileImports(file),
1129
- provider.getFileExports(file),
1130
- ]);
1131
- // Query callers/callees for each exported symbol
1132
- const callerResults = await Promise.all(exports.slice(0, 10).map((sym) => provider.getCallers(sym, file)));
1133
- const calleeResults = await Promise.all(exports.slice(0, 10).map((sym) => provider.getCallees(sym, file)));
1134
- const callers = callerResults.flat();
1135
- const callees = calleeResults.flat();
1136
- return { file, callers, callees, imports, exports };
1137
- });
1138
- const settled = await Promise.allSettled(queries);
1139
- for (const outcome of settled) {
1140
- if (outcome.status === 'fulfilled') {
1141
- results.push(outcome.value);
1142
- }
1143
- }
1144
- const durationMs = Date.now() - startTime;
1145
- const withData = results.filter((r) => r.callers.length > 0 || r.callees.length > 0 || r.imports.length > 0).length;
1146
- emit({
1147
- step: 'code-intel',
1148
- message: `Code intelligence: ${withData}/${results.length} files with structural data (${durationMs}ms)`,
1149
- });
1150
- return results;
1151
- }
1152
- catch (error) {
1153
- console.warn('[ghagga] Code intelligence query failed (degrading gracefully):', error instanceof Error ? error.message : String(error));
1154
- failedSteps.push({
1155
- step: 'code-intel',
1156
- error: error instanceof Error ? error.message : String(error),
1157
- });
1158
- emit({ step: 'code-intel', message: 'Code intelligence: failed — continuing without' });
1159
- return [];
1160
- }
1161
- }
1162
- /**
1163
- * Create a SKIPPED result when all files are filtered out.
1164
- */
1165
- function createSkippedResult(input, startTime) {
1166
- const primary = input.providerChain?.[0];
1167
- // Build a dynamic skipped result (legacy keys always present)
1168
- const skippedToolResult = { status: 'skipped', findings: [], executionTimeMs: 0 };
1169
- const staticAnalysis = {
1170
- semgrep: { ...skippedToolResult },
1171
- trivy: { ...skippedToolResult },
1172
- cpd: { ...skippedToolResult },
1173
- };
1174
- // Collect all tool names for the toolsSkipped metadata
1175
- const allToolNames = ['semgrep', 'trivy', 'cpd'];
1176
- // When registry is enabled, include all registered tools as skipped
1177
- if (isToolRegistryEnabled()) {
1178
- initializeDefaultTools();
1179
- for (const tool of toolRegistry.getAll()) {
1180
- if (!staticAnalysis[tool.name]) {
1181
- staticAnalysis[tool.name] = { ...skippedToolResult };
1182
- }
1183
- if (!allToolNames.includes(tool.name)) {
1184
- allToolNames.push(tool.name);
1185
- }
1186
- }
1187
- }
1188
- return {
1189
- status: 'SKIPPED',
1190
- summary: 'All files in the diff matched ignore patterns. No review was performed.',
1191
- findings: [],
1192
- staticAnalysis,
1193
- memoryContext: null,
1194
- metadata: {
1195
- mode: input.mode,
1196
- provider: primary?.provider ?? input.provider ?? 'none',
1197
- model: primary?.model ?? input.model ?? 'unknown',
1198
- tokensUsed: 0,
1199
- executionTimeMs: Date.now() - startTime,
1200
- toolsRun: [],
1201
- toolsSkipped: allToolNames,
1202
- },
1203
- };
1204
- }
1205
- /**
1206
- * Create a result with only static analysis findings (no AI).
1207
- * Used when AI review is disabled or when all providers fail.
1208
- */
1209
- function createStaticOnlyResult(staticResult, mode, startTime) {
1210
- // Determine status from static findings severity (dynamic — all tools)
1211
- const allFindings = Object.values(staticResult).flatMap((toolResult) => toolResult && typeof toolResult === 'object' && 'findings' in toolResult
1212
- ? toolResult.findings
1213
- : []);
1214
- const hasCriticalOrHigh = allFindings.some((f) => f.severity === 'critical' || f.severity === 'high');
1215
- return {
1216
- status: hasCriticalOrHigh ? 'FAILED' : 'PASSED',
1217
- summary: allFindings.length > 0
1218
- ? `Static analysis found ${allFindings.length} finding(s). AI review was not performed.`
1219
- : 'Static analysis found no issues. AI review was not performed.',
1220
- findings: [], // Will be merged in step 7
1221
- staticAnalysis: staticResult,
1222
- memoryContext: null,
1223
- metadata: {
1224
- mode,
1225
- provider: 'none',
1226
- model: 'static-only',
1227
- tokensUsed: 0,
1228
- executionTimeMs: Date.now() - startTime,
1229
- toolsRun: [],
1230
- toolsSkipped: [],
1231
- },
1232
- };
35
+ // ── Steps 1 → 4: prepare (validate + parse/filter + budget) ──
36
+ // Constructs the shared PipelineState base, or short-circuits with a
37
+ // final result (flood-skip / all-files-filtered).
38
+ const prepared = await prepare(input);
39
+ if (prepared.kind === 'early') {
40
+ return prepared.result;
41
+ }
42
+ const base = prepared.base;
43
+ // ── Steps 5 → 5.4: gather context (trio ∥ + prompts + checklist) ──
44
+ await gatherContext(base);
45
+ // ── Steps 5.5 → 6: execute (enhance compute + trust + dispatch) ──
46
+ const result = await execute(base);
47
+ // Attach the result created by execute. Object.assign keeps the SAME
48
+ // base object (no copy — aliases like `failedSteps` stay intact) and
49
+ // upgrades it to a full PipelineState.
50
+ const state = Object.assign(base, { result });
51
+ // ── Steps 7 → 7.8: enrich (merge + post-processing) ─────────
52
+ await enrich(state);
53
+ // ── Steps 8 → 9: finalize (persist + status downgrade) ──────
54
+ await finalize(state);
55
+ return state.result;
1233
56
  }
1234
57
  //# sourceMappingURL=pipeline.js.map