@kb-labs/review-core 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +268 -0
- package/dist/index.d.ts +412 -0
- package/dist/index.js +1542 -0
- package/dist/index.js.map +1 -0
- package/package.json +43 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1542 @@
|
|
|
1
|
+
import { LinterRunner, deduplicateFindings } from '@kb-labs/review-heuristic';
|
|
2
|
+
import { runLLMLiteAnalysis } from '@kb-labs/review-llm';
|
|
3
|
+
import { useConfig, useLogger, useAnalytics, useLLM } from '@kb-labs/sdk';
|
|
4
|
+
import { simpleGit } from 'simple-git';
|
|
5
|
+
import * as path from 'path';
|
|
6
|
+
import { join, dirname } from 'path';
|
|
7
|
+
import { createHash } from 'crypto';
|
|
8
|
+
import { readFile, mkdir, writeFile } from 'fs/promises';
|
|
9
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
|
10
|
+
|
|
11
|
+
// src/orchestrator.ts
|
|
12
|
+
|
|
13
|
+
// src/presets/builtin-presets.ts
|
|
14
|
+
var defaultPreset = {
|
|
15
|
+
id: "default",
|
|
16
|
+
name: "Default",
|
|
17
|
+
description: "Balanced rules for most TypeScript/JavaScript projects",
|
|
18
|
+
rules: [
|
|
19
|
+
"eslint:recommended",
|
|
20
|
+
"@typescript-eslint/recommended"
|
|
21
|
+
],
|
|
22
|
+
excludeRules: [],
|
|
23
|
+
engines: {
|
|
24
|
+
eslint: {
|
|
25
|
+
enabled: true,
|
|
26
|
+
config: {
|
|
27
|
+
parser: "@typescript-eslint/parser",
|
|
28
|
+
parserOptions: {
|
|
29
|
+
ecmaVersion: 2022,
|
|
30
|
+
sourceType: "module"
|
|
31
|
+
},
|
|
32
|
+
plugins: ["@typescript-eslint"],
|
|
33
|
+
rules: {
|
|
34
|
+
// Warnings for common issues
|
|
35
|
+
"@typescript-eslint/no-explicit-any": "warn",
|
|
36
|
+
"@typescript-eslint/no-unused-vars": ["warn", {
|
|
37
|
+
argsIgnorePattern: "^_",
|
|
38
|
+
varsIgnorePattern: "^_"
|
|
39
|
+
}],
|
|
40
|
+
"no-console": "warn",
|
|
41
|
+
"no-debugger": "error"
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
include: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
|
47
|
+
exclude: ["**/node_modules/**", "**/dist/**", "**/build/**"]
|
|
48
|
+
};
|
|
49
|
+
var typescriptStrictPreset = {
|
|
50
|
+
id: "typescript-strict",
|
|
51
|
+
name: "TypeScript Strict",
|
|
52
|
+
description: "Strict TypeScript rules with @typescript-eslint/recommended",
|
|
53
|
+
rules: [
|
|
54
|
+
"eslint:recommended",
|
|
55
|
+
"@typescript-eslint/recommended",
|
|
56
|
+
"@typescript-eslint/recommended-requiring-type-checking"
|
|
57
|
+
],
|
|
58
|
+
excludeRules: [],
|
|
59
|
+
engines: {
|
|
60
|
+
eslint: {
|
|
61
|
+
enabled: true,
|
|
62
|
+
config: {
|
|
63
|
+
parser: "@typescript-eslint/parser",
|
|
64
|
+
parserOptions: {
|
|
65
|
+
ecmaVersion: "latest",
|
|
66
|
+
sourceType: "module",
|
|
67
|
+
project: true
|
|
68
|
+
},
|
|
69
|
+
extends: [
|
|
70
|
+
"eslint:recommended",
|
|
71
|
+
"plugin:@typescript-eslint/recommended",
|
|
72
|
+
"plugin:@typescript-eslint/recommended-requiring-type-checking"
|
|
73
|
+
],
|
|
74
|
+
rules: {
|
|
75
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
76
|
+
"@typescript-eslint/explicit-function-return-type": "warn",
|
|
77
|
+
"@typescript-eslint/no-unused-vars": "error",
|
|
78
|
+
"@typescript-eslint/strict-boolean-expressions": "warn"
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
},
|
|
83
|
+
include: ["**/*.ts", "**/*.tsx"],
|
|
84
|
+
exclude: ["**/node_modules/**", "**/dist/**", "**/build/**", "**/*.test.ts", "**/*.spec.ts"],
|
|
85
|
+
severity: {
|
|
86
|
+
failOn: "high"
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
var reactPreset = {
|
|
90
|
+
id: "react",
|
|
91
|
+
name: "React",
|
|
92
|
+
description: "React best practices with hooks rules",
|
|
93
|
+
rules: [
|
|
94
|
+
"eslint:recommended",
|
|
95
|
+
"plugin:react/recommended",
|
|
96
|
+
"plugin:react-hooks/recommended"
|
|
97
|
+
],
|
|
98
|
+
excludeRules: [],
|
|
99
|
+
engines: {
|
|
100
|
+
eslint: {
|
|
101
|
+
enabled: true,
|
|
102
|
+
config: {
|
|
103
|
+
parserOptions: {
|
|
104
|
+
ecmaVersion: "latest",
|
|
105
|
+
sourceType: "module",
|
|
106
|
+
ecmaFeatures: {
|
|
107
|
+
jsx: true
|
|
108
|
+
}
|
|
109
|
+
},
|
|
110
|
+
extends: [
|
|
111
|
+
"eslint:recommended",
|
|
112
|
+
"plugin:react/recommended",
|
|
113
|
+
"plugin:react-hooks/recommended"
|
|
114
|
+
],
|
|
115
|
+
settings: {
|
|
116
|
+
react: {
|
|
117
|
+
version: "detect"
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
rules: {
|
|
121
|
+
"react/react-in-jsx-scope": "off",
|
|
122
|
+
// Not needed in React 17+
|
|
123
|
+
"react-hooks/rules-of-hooks": "error",
|
|
124
|
+
"react-hooks/exhaustive-deps": "warn"
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
include: ["**/*.tsx", "**/*.jsx"],
|
|
130
|
+
exclude: ["**/node_modules/**", "**/dist/**", "**/build/**"]
|
|
131
|
+
};
|
|
132
|
+
var securityPreset = {
|
|
133
|
+
id: "security",
|
|
134
|
+
name: "Security",
|
|
135
|
+
description: "Security-focused rules to detect vulnerabilities",
|
|
136
|
+
rules: [
|
|
137
|
+
"eslint:recommended",
|
|
138
|
+
"plugin:security/recommended"
|
|
139
|
+
],
|
|
140
|
+
excludeRules: [],
|
|
141
|
+
engines: {
|
|
142
|
+
eslint: {
|
|
143
|
+
enabled: true,
|
|
144
|
+
config: {
|
|
145
|
+
extends: [
|
|
146
|
+
"eslint:recommended",
|
|
147
|
+
"plugin:security/recommended"
|
|
148
|
+
],
|
|
149
|
+
rules: {
|
|
150
|
+
"no-eval": "error",
|
|
151
|
+
"no-implied-eval": "error",
|
|
152
|
+
"no-new-func": "error",
|
|
153
|
+
"security/detect-eval-with-expression": "error",
|
|
154
|
+
"security/detect-non-literal-regexp": "warn",
|
|
155
|
+
"security/detect-unsafe-regex": "error"
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
include: ["**/*.ts", "**/*.tsx", "**/*.js", "**/*.jsx"],
|
|
161
|
+
exclude: ["**/node_modules/**", "**/dist/**", "**/build/**"],
|
|
162
|
+
severity: {
|
|
163
|
+
failOn: "medium"
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
var kbLabsPreset = {
|
|
167
|
+
id: "kb-labs",
|
|
168
|
+
name: "KB Labs",
|
|
169
|
+
description: "Comprehensive architectural and security rules for KB Labs platform",
|
|
170
|
+
rules: [
|
|
171
|
+
"eslint:recommended",
|
|
172
|
+
"@typescript-eslint/recommended"
|
|
173
|
+
],
|
|
174
|
+
excludeRules: [],
|
|
175
|
+
engines: {
|
|
176
|
+
eslint: {
|
|
177
|
+
enabled: true,
|
|
178
|
+
config: {
|
|
179
|
+
parser: "@typescript-eslint/parser",
|
|
180
|
+
parserOptions: {
|
|
181
|
+
ecmaVersion: "latest",
|
|
182
|
+
sourceType: "module",
|
|
183
|
+
project: true
|
|
184
|
+
},
|
|
185
|
+
plugins: ["@typescript-eslint", "security"],
|
|
186
|
+
rules: {
|
|
187
|
+
// Type Safety
|
|
188
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
189
|
+
"@typescript-eslint/no-unsafe-assignment": "warn",
|
|
190
|
+
"@typescript-eslint/no-unsafe-member-access": "warn",
|
|
191
|
+
"@typescript-eslint/no-unsafe-call": "warn",
|
|
192
|
+
"@typescript-eslint/no-unsafe-return": "warn",
|
|
193
|
+
// Naming Conventions
|
|
194
|
+
"@typescript-eslint/naming-convention": [
|
|
195
|
+
"error",
|
|
196
|
+
{
|
|
197
|
+
selector: "interface",
|
|
198
|
+
format: ["PascalCase"],
|
|
199
|
+
prefix: ["I"]
|
|
200
|
+
},
|
|
201
|
+
{
|
|
202
|
+
selector: "typeAlias",
|
|
203
|
+
format: ["PascalCase"]
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
selector: "class",
|
|
207
|
+
format: ["PascalCase"]
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
selector: "function",
|
|
211
|
+
format: ["camelCase", "PascalCase"]
|
|
212
|
+
},
|
|
213
|
+
{
|
|
214
|
+
selector: "variable",
|
|
215
|
+
format: ["camelCase", "UPPER_CASE", "PascalCase"],
|
|
216
|
+
leadingUnderscore: "allow"
|
|
217
|
+
}
|
|
218
|
+
],
|
|
219
|
+
// Code Quality
|
|
220
|
+
"@typescript-eslint/no-unused-vars": ["error", {
|
|
221
|
+
argsIgnorePattern: "^_",
|
|
222
|
+
varsIgnorePattern: "^_",
|
|
223
|
+
caughtErrors: "all"
|
|
224
|
+
}],
|
|
225
|
+
"@typescript-eslint/explicit-function-return-type": ["warn", {
|
|
226
|
+
allowExpressions: true,
|
|
227
|
+
allowTypedFunctionExpressions: true,
|
|
228
|
+
allowHigherOrderFunctions: true
|
|
229
|
+
}],
|
|
230
|
+
"@typescript-eslint/no-floating-promises": "error",
|
|
231
|
+
"@typescript-eslint/await-thenable": "error",
|
|
232
|
+
"@typescript-eslint/no-misused-promises": "error",
|
|
233
|
+
// Security
|
|
234
|
+
"no-eval": "error",
|
|
235
|
+
"no-implied-eval": "error",
|
|
236
|
+
"no-new-func": "error",
|
|
237
|
+
"security/detect-eval-with-expression": "error",
|
|
238
|
+
"security/detect-non-literal-regexp": "warn",
|
|
239
|
+
"security/detect-unsafe-regex": "error",
|
|
240
|
+
"security/detect-buffer-noassert": "error",
|
|
241
|
+
"security/detect-child-process": "warn",
|
|
242
|
+
"security/detect-disable-mustache-escape": "error",
|
|
243
|
+
"security/detect-no-csrf-before-method-override": "error",
|
|
244
|
+
"security/detect-non-literal-fs-filename": "warn",
|
|
245
|
+
"security/detect-non-literal-require": "warn",
|
|
246
|
+
"security/detect-object-injection": "warn",
|
|
247
|
+
"security/detect-possible-timing-attacks": "warn",
|
|
248
|
+
"security/detect-pseudoRandomBytes": "error",
|
|
249
|
+
// Best Practices
|
|
250
|
+
"no-console": ["warn", { allow: ["warn", "error"] }],
|
|
251
|
+
"no-debugger": "error",
|
|
252
|
+
"no-alert": "error",
|
|
253
|
+
"prefer-const": "error",
|
|
254
|
+
"no-var": "error",
|
|
255
|
+
"eqeqeq": ["error", "always", { null: "ignore" }]
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
},
|
|
260
|
+
// LLM Analyzers context
|
|
261
|
+
context: {
|
|
262
|
+
projectType: "monorepo",
|
|
263
|
+
framework: "nodejs",
|
|
264
|
+
language: "typescript",
|
|
265
|
+
conventions: {
|
|
266
|
+
naming: "Interfaces must be prefixed with I (e.g., ILLM, ICache). Classes use PascalCase. Functions and variables use camelCase.",
|
|
267
|
+
architecture: "Follow V3 plugin system patterns. Use adapter pattern for external services. Implement proper error handling with typed errors.",
|
|
268
|
+
security: "Never use eval() or Function(). Validate all external inputs. Use parameterized queries. Sanitize file paths."
|
|
269
|
+
},
|
|
270
|
+
adrs: [
|
|
271
|
+
"ADR-0046: LLM Router for tier-based model selection",
|
|
272
|
+
"ADR-0048: Metadata-based routing with wrapper chain",
|
|
273
|
+
"ADR-0047: Multi-adapter architecture"
|
|
274
|
+
]
|
|
275
|
+
},
|
|
276
|
+
include: ["**/packages/*/src/**/*.ts", "**/packages/*/src/**/*.tsx"],
|
|
277
|
+
exclude: [
|
|
278
|
+
"**/node_modules/**",
|
|
279
|
+
"**/dist/**",
|
|
280
|
+
"**/build/**",
|
|
281
|
+
"**/*.test.ts",
|
|
282
|
+
"**/*.spec.ts",
|
|
283
|
+
"**/__tests__/**"
|
|
284
|
+
],
|
|
285
|
+
severity: {
|
|
286
|
+
failOn: "high"
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
var kbLabsStrictPreset = {
|
|
290
|
+
id: "kb-labs-strict",
|
|
291
|
+
name: "KB Labs Strict",
|
|
292
|
+
description: "Maximum strictness for security-critical packages (core, runtime, plugin-runtime)",
|
|
293
|
+
rules: [
|
|
294
|
+
"eslint:recommended",
|
|
295
|
+
"@typescript-eslint/recommended",
|
|
296
|
+
"@typescript-eslint/recommended-requiring-type-checking"
|
|
297
|
+
],
|
|
298
|
+
excludeRules: [],
|
|
299
|
+
engines: {
|
|
300
|
+
eslint: {
|
|
301
|
+
enabled: true,
|
|
302
|
+
config: {
|
|
303
|
+
parser: "@typescript-eslint/parser",
|
|
304
|
+
parserOptions: {
|
|
305
|
+
ecmaVersion: "latest",
|
|
306
|
+
sourceType: "module",
|
|
307
|
+
project: true
|
|
308
|
+
},
|
|
309
|
+
plugins: ["@typescript-eslint", "security"],
|
|
310
|
+
rules: {
|
|
311
|
+
// Type Safety - STRICT
|
|
312
|
+
"@typescript-eslint/no-explicit-any": "error",
|
|
313
|
+
"@typescript-eslint/no-unsafe-assignment": "error",
|
|
314
|
+
"@typescript-eslint/no-unsafe-member-access": "error",
|
|
315
|
+
"@typescript-eslint/no-unsafe-call": "error",
|
|
316
|
+
"@typescript-eslint/no-unsafe-return": "error",
|
|
317
|
+
"@typescript-eslint/explicit-function-return-type": "error",
|
|
318
|
+
"@typescript-eslint/explicit-module-boundary-types": "error",
|
|
319
|
+
"@typescript-eslint/strict-boolean-expressions": "error",
|
|
320
|
+
"@typescript-eslint/no-non-null-assertion": "error",
|
|
321
|
+
// Naming Conventions - STRICT
|
|
322
|
+
"@typescript-eslint/naming-convention": [
|
|
323
|
+
"error",
|
|
324
|
+
{
|
|
325
|
+
selector: "interface",
|
|
326
|
+
format: ["PascalCase"],
|
|
327
|
+
prefix: ["I"]
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
selector: "typeAlias",
|
|
331
|
+
format: ["PascalCase"]
|
|
332
|
+
},
|
|
333
|
+
{
|
|
334
|
+
selector: "class",
|
|
335
|
+
format: ["PascalCase"]
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
selector: "function",
|
|
339
|
+
format: ["camelCase"]
|
|
340
|
+
},
|
|
341
|
+
{
|
|
342
|
+
selector: "variable",
|
|
343
|
+
format: ["camelCase", "UPPER_CASE"],
|
|
344
|
+
leadingUnderscore: "forbid"
|
|
345
|
+
}
|
|
346
|
+
],
|
|
347
|
+
// Code Quality - STRICT
|
|
348
|
+
"@typescript-eslint/no-unused-vars": ["error", {
|
|
349
|
+
argsIgnorePattern: "^_",
|
|
350
|
+
varsIgnorePattern: "^_",
|
|
351
|
+
caughtErrors: "all",
|
|
352
|
+
ignoreRestSiblings: false
|
|
353
|
+
}],
|
|
354
|
+
"@typescript-eslint/no-floating-promises": "error",
|
|
355
|
+
"@typescript-eslint/await-thenable": "error",
|
|
356
|
+
"@typescript-eslint/no-misused-promises": "error",
|
|
357
|
+
"@typescript-eslint/require-await": "error",
|
|
358
|
+
"@typescript-eslint/no-unnecessary-type-assertion": "error",
|
|
359
|
+
// Security - MAXIMUM
|
|
360
|
+
"no-eval": "error",
|
|
361
|
+
"no-implied-eval": "error",
|
|
362
|
+
"no-new-func": "error",
|
|
363
|
+
"security/detect-eval-with-expression": "error",
|
|
364
|
+
"security/detect-non-literal-regexp": "error",
|
|
365
|
+
"security/detect-unsafe-regex": "error",
|
|
366
|
+
"security/detect-buffer-noassert": "error",
|
|
367
|
+
"security/detect-child-process": "error",
|
|
368
|
+
"security/detect-disable-mustache-escape": "error",
|
|
369
|
+
"security/detect-no-csrf-before-method-override": "error",
|
|
370
|
+
"security/detect-non-literal-fs-filename": "error",
|
|
371
|
+
"security/detect-non-literal-require": "error",
|
|
372
|
+
"security/detect-object-injection": "error",
|
|
373
|
+
"security/detect-possible-timing-attacks": "error",
|
|
374
|
+
"security/detect-pseudoRandomBytes": "error",
|
|
375
|
+
// Best Practices - STRICT
|
|
376
|
+
"no-console": "error",
|
|
377
|
+
"no-debugger": "error",
|
|
378
|
+
"no-alert": "error",
|
|
379
|
+
"prefer-const": "error",
|
|
380
|
+
"no-var": "error",
|
|
381
|
+
"eqeqeq": ["error", "always"],
|
|
382
|
+
"no-param-reassign": "error"
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
},
|
|
387
|
+
// LLM Analyzers context - STRICT
|
|
388
|
+
context: {
|
|
389
|
+
projectType: "monorepo",
|
|
390
|
+
framework: "nodejs",
|
|
391
|
+
language: "typescript",
|
|
392
|
+
conventions: {
|
|
393
|
+
naming: "Strict naming: Interfaces MUST have I prefix. NO leading underscores. Constants MUST be UPPER_CASE.",
|
|
394
|
+
architecture: "V3 plugin system with strict separation of concerns. ALL functions must have explicit return types. NO any types allowed.",
|
|
395
|
+
security: "Zero tolerance for security issues. ALL inputs must be validated. ALL promises must be awaited or explicitly handled. NO eval/Function/console allowed."
|
|
396
|
+
},
|
|
397
|
+
adrs: [
|
|
398
|
+
"ADR-0046: LLM Router",
|
|
399
|
+
"ADR-0048: Metadata-based routing",
|
|
400
|
+
"ADR-0047: Multi-adapter architecture",
|
|
401
|
+
"Security: Sandbox isolation with hardcoded deny patterns",
|
|
402
|
+
"Security: Permission-based file system access"
|
|
403
|
+
]
|
|
404
|
+
},
|
|
405
|
+
include: [
|
|
406
|
+
"**/packages/core-*/src/**/*.ts",
|
|
407
|
+
"**/packages/plugin-runtime/src/**/*.ts",
|
|
408
|
+
"**/packages/state-*/src/**/*.ts"
|
|
409
|
+
],
|
|
410
|
+
exclude: [
|
|
411
|
+
"**/node_modules/**",
|
|
412
|
+
"**/dist/**",
|
|
413
|
+
"**/build/**",
|
|
414
|
+
"**/*.test.ts",
|
|
415
|
+
"**/*.spec.ts",
|
|
416
|
+
"**/__tests__/**"
|
|
417
|
+
],
|
|
418
|
+
severity: {
|
|
419
|
+
failOn: "medium"
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
var builtinPresets = [
|
|
423
|
+
defaultPreset,
|
|
424
|
+
typescriptStrictPreset,
|
|
425
|
+
reactPreset,
|
|
426
|
+
securityPreset,
|
|
427
|
+
kbLabsPreset,
|
|
428
|
+
kbLabsStrictPreset
|
|
429
|
+
];
|
|
430
|
+
function deepMerge(target, source) {
|
|
431
|
+
const result = { ...target };
|
|
432
|
+
for (const key in source) {
|
|
433
|
+
const sourceValue = source[key];
|
|
434
|
+
const targetValue = result[key];
|
|
435
|
+
if (sourceValue === void 0) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (typeof sourceValue === "object" && sourceValue !== null && !Array.isArray(sourceValue) && typeof targetValue === "object" && targetValue !== null && !Array.isArray(targetValue)) {
|
|
439
|
+
result[key] = deepMerge(targetValue, sourceValue);
|
|
440
|
+
} else if (Array.isArray(sourceValue) && Array.isArray(targetValue)) {
|
|
441
|
+
result[key] = [.../* @__PURE__ */ new Set([...targetValue, ...sourceValue])];
|
|
442
|
+
} else {
|
|
443
|
+
result[key] = sourceValue;
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return result;
|
|
447
|
+
}
|
|
448
|
+
var PresetLoader = class {
|
|
449
|
+
presets;
|
|
450
|
+
configLoaded = false;
|
|
451
|
+
constructor() {
|
|
452
|
+
this.presets = /* @__PURE__ */ new Map();
|
|
453
|
+
this.loadBuiltinPresets();
|
|
454
|
+
}
|
|
455
|
+
/**
|
|
456
|
+
* Load builtin presets
|
|
457
|
+
*/
|
|
458
|
+
loadBuiltinPresets() {
|
|
459
|
+
for (const preset of builtinPresets) {
|
|
460
|
+
this.presets.set(preset.id, preset);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/**
|
|
464
|
+
* Resolve preset inheritance (extends) with cycle detection
|
|
465
|
+
*/
|
|
466
|
+
resolveInheritance(preset, visited = /* @__PURE__ */ new Set()) {
|
|
467
|
+
if (!preset.extends) {
|
|
468
|
+
return preset;
|
|
469
|
+
}
|
|
470
|
+
if (visited.has(preset.id)) {
|
|
471
|
+
const chain = Array.from(visited).join(" -> ") + " -> " + preset.id;
|
|
472
|
+
throw new Error(
|
|
473
|
+
`Circular inheritance detected in presets: ${chain}`
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
visited.add(preset.id);
|
|
477
|
+
const parent = this.presets.get(preset.extends);
|
|
478
|
+
if (!parent) {
|
|
479
|
+
throw new Error(
|
|
480
|
+
`Preset '${preset.id}' extends '${preset.extends}', but parent preset not found`
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
const resolvedParent = this.resolveInheritance(parent, visited);
|
|
484
|
+
const merged = deepMerge(resolvedParent, preset);
|
|
485
|
+
delete merged.extends;
|
|
486
|
+
return merged;
|
|
487
|
+
}
|
|
488
|
+
/**
|
|
489
|
+
* Load custom presets from kb.config.json and preset files
|
|
490
|
+
* Called lazily on first preset access
|
|
491
|
+
*/
|
|
492
|
+
async loadConfigPresets() {
|
|
493
|
+
if (this.configLoaded) {
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
try {
|
|
497
|
+
const config = await useConfig();
|
|
498
|
+
if (config?.presets) {
|
|
499
|
+
for (const presetOrPath of config.presets) {
|
|
500
|
+
if (typeof presetOrPath === "string") {
|
|
501
|
+
await this.loadPresetFromFile(presetOrPath);
|
|
502
|
+
} else {
|
|
503
|
+
this.presets.set(presetOrPath.id, presetOrPath);
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
await this.scanPresetsDirectory();
|
|
508
|
+
this.configLoaded = true;
|
|
509
|
+
} catch {
|
|
510
|
+
this.configLoaded = true;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* Auto-scan .kb/ai-review/presets/ directory for preset files
|
|
515
|
+
*/
|
|
516
|
+
async scanPresetsDirectory() {
|
|
517
|
+
try {
|
|
518
|
+
const fs = await import('fs/promises');
|
|
519
|
+
const pathModule = await import('path');
|
|
520
|
+
const presetsDir = pathModule.join(process.cwd(), ".kb", "ai-review", "presets");
|
|
521
|
+
try {
|
|
522
|
+
await fs.access(presetsDir);
|
|
523
|
+
} catch {
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
const entries = await fs.readdir(presetsDir, { withFileTypes: true });
|
|
527
|
+
const jsonFiles = entries.filter((e) => e.isFile() && e.name.endsWith(".json"));
|
|
528
|
+
for (const file of jsonFiles) {
|
|
529
|
+
if (file.name.includes("..") || file.name.includes(pathModule.sep)) {
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
const filePath = pathModule.join(presetsDir, file.name);
|
|
533
|
+
try {
|
|
534
|
+
const content = await fs.readFile(filePath, "utf-8");
|
|
535
|
+
const preset = JSON.parse(content);
|
|
536
|
+
if (preset.id) {
|
|
537
|
+
this.presets.set(preset.id, preset);
|
|
538
|
+
}
|
|
539
|
+
} catch {
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} catch {
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
/**
|
|
546
|
+
* Load preset from JSON file
|
|
547
|
+
*/
|
|
548
|
+
async loadPresetFromFile(relativePath) {
|
|
549
|
+
try {
|
|
550
|
+
const fs = await import('fs/promises');
|
|
551
|
+
const path2 = await import('path');
|
|
552
|
+
const configDir = path2.join(process.cwd(), ".kb");
|
|
553
|
+
const presetPath = path2.join(configDir, relativePath);
|
|
554
|
+
const content = await fs.readFile(presetPath, "utf-8");
|
|
555
|
+
const preset = JSON.parse(content);
|
|
556
|
+
this.presets.set(preset.id, preset);
|
|
557
|
+
} catch (error) {
|
|
558
|
+
useLogger()?.debug(`[PresetLoader] Failed to load preset from ${relativePath}:`, { error });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
/**
|
|
562
|
+
* Load atomic rule from .md file
|
|
563
|
+
*/
|
|
564
|
+
async loadAtomicRule(category, ruleName) {
|
|
565
|
+
try {
|
|
566
|
+
const fs = await import('fs/promises');
|
|
567
|
+
const path2 = await import('path');
|
|
568
|
+
const configDir = path2.join(process.cwd(), ".kb");
|
|
569
|
+
const rulePath = path2.join(configDir, "ai-review", "rules", category, `${ruleName}.md`);
|
|
570
|
+
const content = await fs.readFile(rulePath, "utf-8");
|
|
571
|
+
return content.trim();
|
|
572
|
+
} catch (error) {
|
|
573
|
+
useLogger()?.debug(`[PresetLoader] Failed to load atomic rule ${category}/${ruleName}:`, { error });
|
|
574
|
+
return void 0;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* Compose atomic rules into convention text
|
|
579
|
+
*/
|
|
580
|
+
async composeRules(category, include, exclude) {
|
|
581
|
+
if (!include || include.length === 0) {
|
|
582
|
+
return "";
|
|
583
|
+
}
|
|
584
|
+
const rules = [];
|
|
585
|
+
for (const ruleName of include) {
|
|
586
|
+
if (exclude?.includes(ruleName)) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
const ruleContent = await this.loadAtomicRule(category, ruleName);
|
|
590
|
+
if (ruleContent) {
|
|
591
|
+
rules.push(ruleContent);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return rules.join("\n\n");
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* Apply atomic rules composition to preset
|
|
598
|
+
* Supports dynamic categories - any category name defined in atomicRules
|
|
599
|
+
*/
|
|
600
|
+
async applyAtomicRules(preset) {
|
|
601
|
+
if (!preset.atomicRules) {
|
|
602
|
+
return preset;
|
|
603
|
+
}
|
|
604
|
+
if (!preset.context) {
|
|
605
|
+
preset.context = {};
|
|
606
|
+
}
|
|
607
|
+
if (!preset.context.conventions) {
|
|
608
|
+
preset.context.conventions = {};
|
|
609
|
+
}
|
|
610
|
+
for (const category in preset.atomicRules) {
|
|
611
|
+
const ruleConfig = preset.atomicRules[category];
|
|
612
|
+
if (!ruleConfig) {
|
|
613
|
+
continue;
|
|
614
|
+
}
|
|
615
|
+
const composedRules = await this.composeRules(
|
|
616
|
+
category,
|
|
617
|
+
ruleConfig.include,
|
|
618
|
+
ruleConfig.exclude
|
|
619
|
+
);
|
|
620
|
+
if (composedRules) {
|
|
621
|
+
const existingConventions = preset.context.conventions[category] || "";
|
|
622
|
+
if (existingConventions) {
|
|
623
|
+
preset.context.conventions[category] = `${composedRules}
|
|
624
|
+
|
|
625
|
+
${existingConventions}`;
|
|
626
|
+
} else {
|
|
627
|
+
preset.context.conventions[category] = composedRules;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
return preset;
|
|
632
|
+
}
|
|
633
|
+
/**
|
|
634
|
+
* Get preset by ID (with inheritance resolved)
|
|
635
|
+
*/
|
|
636
|
+
async getPreset(id) {
|
|
637
|
+
await this.loadConfigPresets();
|
|
638
|
+
const preset = this.presets.get(id);
|
|
639
|
+
if (!preset) {
|
|
640
|
+
return void 0;
|
|
641
|
+
}
|
|
642
|
+
const resolved = this.resolveInheritance(preset);
|
|
643
|
+
return this.applyAtomicRules(resolved);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Get preset by ID or throw error
|
|
647
|
+
*/
|
|
648
|
+
async getPresetOrThrow(id) {
|
|
649
|
+
const preset = await this.getPreset(id);
|
|
650
|
+
if (!preset) {
|
|
651
|
+
const available = Array.from(this.presets.keys()).join(", ");
|
|
652
|
+
throw new Error(
|
|
653
|
+
`Preset not found: "${id}"
|
|
654
|
+
Available presets: ${available}`
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
return preset;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* List all available presets
|
|
661
|
+
*/
|
|
662
|
+
async listPresets() {
|
|
663
|
+
await this.loadConfigPresets();
|
|
664
|
+
return Array.from(this.presets.values());
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Register custom preset
|
|
668
|
+
*/
|
|
669
|
+
registerPreset(preset) {
|
|
670
|
+
this.presets.set(preset.id, preset);
|
|
671
|
+
}
|
|
672
|
+
};
|
|
673
|
+
var globalLoader;
|
|
674
|
+
function getPresetLoader() {
|
|
675
|
+
if (!globalLoader) {
|
|
676
|
+
globalLoader = new PresetLoader();
|
|
677
|
+
}
|
|
678
|
+
return globalLoader;
|
|
679
|
+
}
|
|
680
|
+
async function loadPreset(id) {
|
|
681
|
+
return getPresetLoader().getPresetOrThrow(id);
|
|
682
|
+
}
|
|
683
|
+
var DiffProvider = class {
|
|
684
|
+
git;
|
|
685
|
+
cwd;
|
|
686
|
+
constructor(cwd) {
|
|
687
|
+
if (!cwd || typeof cwd !== "string") {
|
|
688
|
+
throw new Error("DiffProvider: cwd must be a non-empty string");
|
|
689
|
+
}
|
|
690
|
+
const resolvedCwd = path.resolve(cwd);
|
|
691
|
+
if (cwd.includes("..") && resolvedCwd !== path.resolve(process.cwd(), cwd)) {
|
|
692
|
+
useLogger()?.debug("[DiffProvider] Potential path traversal detected in cwd:", { cwd, resolvedCwd });
|
|
693
|
+
}
|
|
694
|
+
this.cwd = resolvedCwd;
|
|
695
|
+
this.git = simpleGit(resolvedCwd);
|
|
696
|
+
}
|
|
697
|
+
/**
|
|
698
|
+
* Get diffs for multiple files in one call (batch operation)
|
|
699
|
+
*/
|
|
700
|
+
async getDiffs(request) {
|
|
701
|
+
const { files, staged = true, unstaged = true, maxLinesPerFile = 500 } = request;
|
|
702
|
+
const diffs = [];
|
|
703
|
+
const errors = [];
|
|
704
|
+
let totalLines = 0;
|
|
705
|
+
const BATCH_SIZE = 10;
|
|
706
|
+
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
707
|
+
const batch = files.slice(i, i + BATCH_SIZE);
|
|
708
|
+
const results = await Promise.allSettled(
|
|
709
|
+
batch.map((file) => this.getFileDiff(file, staged, unstaged, maxLinesPerFile))
|
|
710
|
+
);
|
|
711
|
+
for (let j = 0; j < results.length; j++) {
|
|
712
|
+
const result = results[j];
|
|
713
|
+
const file = batch[j];
|
|
714
|
+
if (result.status === "fulfilled" && result.value) {
|
|
715
|
+
diffs.push(result.value);
|
|
716
|
+
totalLines += result.value.diff.split("\n").length;
|
|
717
|
+
} else if (result.status === "rejected") {
|
|
718
|
+
errors.push({ file, error: result.reason?.message ?? "Unknown error" });
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
return { diffs, errors, totalLines };
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Get diff for a single file
|
|
726
|
+
*/
|
|
727
|
+
async getFileDiff(file, staged = true, unstaged = true, maxLines = 500) {
|
|
728
|
+
try {
|
|
729
|
+
let diff = "";
|
|
730
|
+
if (staged) {
|
|
731
|
+
const stagedDiff = await this.git.diff(["--cached", "--", file]);
|
|
732
|
+
if (stagedDiff) {
|
|
733
|
+
diff = stagedDiff;
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (unstaged && !diff) {
|
|
737
|
+
const unstagedDiff = await this.git.diff(["--", file]);
|
|
738
|
+
if (unstagedDiff) {
|
|
739
|
+
diff = unstagedDiff;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
if (!diff) {
|
|
743
|
+
try {
|
|
744
|
+
const headDiff = await this.git.diff(["HEAD", "--", file]);
|
|
745
|
+
if (headDiff) {
|
|
746
|
+
diff = headDiff;
|
|
747
|
+
}
|
|
748
|
+
} catch {
|
|
749
|
+
return null;
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
if (!diff) {
|
|
753
|
+
return null;
|
|
754
|
+
}
|
|
755
|
+
const lines = diff.split("\n");
|
|
756
|
+
if (lines.length > maxLines) {
|
|
757
|
+
diff = lines.slice(0, maxLines).join("\n") + `
|
|
758
|
+
... (truncated, ${lines.length - maxLines} more lines)`;
|
|
759
|
+
}
|
|
760
|
+
return this.parseDiff(file, diff);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
useLogger()?.debug(`[DiffProvider] Error getting diff for ${file}:`, { error });
|
|
763
|
+
return null;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* Parse unified diff into structured format
|
|
768
|
+
*/
|
|
769
|
+
// eslint-disable-next-line sonarjs/cognitive-complexity -- Diff parsing logic: detects file modes (new/deleted/renamed), parses hunks, tracks line numbers, counts additions/deletions
|
|
770
|
+
parseDiff(file, diff) {
|
|
771
|
+
const hunks = [];
|
|
772
|
+
const changedLines = /* @__PURE__ */ new Set();
|
|
773
|
+
let additions = 0;
|
|
774
|
+
let deletions = 0;
|
|
775
|
+
let isNewFile = false;
|
|
776
|
+
let isDeleted = false;
|
|
777
|
+
let isRenamed = false;
|
|
778
|
+
if (diff.includes("new file mode")) {
|
|
779
|
+
isNewFile = true;
|
|
780
|
+
}
|
|
781
|
+
if (diff.includes("deleted file mode")) {
|
|
782
|
+
isDeleted = true;
|
|
783
|
+
}
|
|
784
|
+
if (diff.includes("rename from") || diff.includes("rename to")) {
|
|
785
|
+
isRenamed = true;
|
|
786
|
+
}
|
|
787
|
+
const lines = diff.split("\n");
|
|
788
|
+
let currentHunkStart = -1;
|
|
789
|
+
let currentHunk = [];
|
|
790
|
+
for (let i = 0; i < lines.length; i++) {
|
|
791
|
+
const line = lines[i];
|
|
792
|
+
const hunkMatch = line.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
|
|
793
|
+
if (hunkMatch) {
|
|
794
|
+
if (currentHunkStart >= 0 && currentHunk.length > 0) {
|
|
795
|
+
const parsed = this.parseHunkContent(currentHunk, currentHunkStart);
|
|
796
|
+
hunks.push(parsed);
|
|
797
|
+
parsed.addedLines.forEach((l) => changedLines.add(l));
|
|
798
|
+
}
|
|
799
|
+
currentHunkStart = parseInt(hunkMatch[3], 10);
|
|
800
|
+
currentHunk = [line];
|
|
801
|
+
} else if (currentHunkStart >= 0) {
|
|
802
|
+
currentHunk.push(line);
|
|
803
|
+
}
|
|
804
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
805
|
+
additions++;
|
|
806
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
807
|
+
deletions++;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
if (currentHunkStart >= 0 && currentHunk.length > 0) {
|
|
811
|
+
const parsed = this.parseHunkContent(currentHunk, currentHunkStart);
|
|
812
|
+
hunks.push(parsed);
|
|
813
|
+
parsed.addedLines.forEach((l) => changedLines.add(l));
|
|
814
|
+
}
|
|
815
|
+
return {
|
|
816
|
+
file,
|
|
817
|
+
diff,
|
|
818
|
+
additions,
|
|
819
|
+
deletions,
|
|
820
|
+
isNewFile,
|
|
821
|
+
isDeleted,
|
|
822
|
+
isRenamed,
|
|
823
|
+
hunks,
|
|
824
|
+
changedLines
|
|
825
|
+
};
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Parse hunk content to extract line numbers
|
|
829
|
+
*/
|
|
830
|
+
parseHunkContent(lines, newStart) {
|
|
831
|
+
const addedLines = [];
|
|
832
|
+
const deletedLines = [];
|
|
833
|
+
const header = lines[0];
|
|
834
|
+
const headerMatch = header.match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
|
|
835
|
+
const oldStart = headerMatch ? parseInt(headerMatch[1], 10) : 0;
|
|
836
|
+
const oldLines = headerMatch && headerMatch[2] ? parseInt(headerMatch[2], 10) : 1;
|
|
837
|
+
const newLines = headerMatch && headerMatch[4] ? parseInt(headerMatch[4], 10) : 1;
|
|
838
|
+
let newLineNum = newStart;
|
|
839
|
+
let oldLineNum = oldStart;
|
|
840
|
+
for (let i = 1; i < lines.length; i++) {
|
|
841
|
+
const line = lines[i];
|
|
842
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
843
|
+
addedLines.push(newLineNum);
|
|
844
|
+
newLineNum++;
|
|
845
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
846
|
+
deletedLines.push(oldLineNum);
|
|
847
|
+
oldLineNum++;
|
|
848
|
+
} else if (line.startsWith(" ") || line === "") {
|
|
849
|
+
newLineNum++;
|
|
850
|
+
oldLineNum++;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
return {
|
|
854
|
+
newStart,
|
|
855
|
+
newLines,
|
|
856
|
+
oldStart,
|
|
857
|
+
oldLines,
|
|
858
|
+
content: lines.join("\n"),
|
|
859
|
+
addedLines,
|
|
860
|
+
deletedLines
|
|
861
|
+
};
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Check if a line number is in the diff (was changed)
|
|
865
|
+
*/
|
|
866
|
+
isLineInDiff(fileDiff, lineNumber) {
|
|
867
|
+
return fileDiff.changedLines.has(lineNumber);
|
|
868
|
+
}
|
|
869
|
+
/**
|
|
870
|
+
* Get context around a specific line (for verification)
|
|
871
|
+
*/
|
|
872
|
+
async getLineContext(file, lineNumber, contextLines = 3) {
|
|
873
|
+
try {
|
|
874
|
+
const fullPath = path.join(this.cwd, file);
|
|
875
|
+
const { readFile: readFile3 } = await import('fs/promises');
|
|
876
|
+
const content = await readFile3(fullPath, "utf-8");
|
|
877
|
+
const lines = content.split("\n");
|
|
878
|
+
const start = Math.max(0, lineNumber - contextLines - 1);
|
|
879
|
+
const end = Math.min(lines.length, lineNumber + contextLines);
|
|
880
|
+
return {
|
|
881
|
+
lines: lines.slice(start, end),
|
|
882
|
+
startLine: start + 1
|
|
883
|
+
};
|
|
884
|
+
} catch {
|
|
885
|
+
return null;
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
function createDiffProvider(cwd) {
|
|
890
|
+
return new DiffProvider(cwd);
|
|
891
|
+
}
|
|
892
|
+
var CACHE_VERSION = 1;
|
|
893
|
+
var CACHE_FILENAME = "llm-findings-cache.json";
|
|
894
|
+
function hashFileContent(content) {
|
|
895
|
+
return createHash("sha256").update(content).digest("hex").slice(0, 16);
|
|
896
|
+
}
|
|
897
|
+
function findingSignature(finding) {
|
|
898
|
+
return `${finding.file}:${finding.line}:${finding.endLine ?? finding.line}:${finding.type}`;
|
|
899
|
+
}
|
|
900
|
+
var FindingsCache = class {
|
|
901
|
+
cwd;
|
|
902
|
+
cachePath;
|
|
903
|
+
data = null;
|
|
904
|
+
constructor(cwd) {
|
|
905
|
+
this.cwd = cwd;
|
|
906
|
+
this.cachePath = join(cwd, ".kb", "ai-review", "cache", CACHE_FILENAME);
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Load cache from disk
|
|
910
|
+
*/
|
|
911
|
+
async load() {
|
|
912
|
+
try {
|
|
913
|
+
const content = await readFile(this.cachePath, "utf-8");
|
|
914
|
+
const parsed = JSON.parse(content);
|
|
915
|
+
if (parsed.version !== CACHE_VERSION) {
|
|
916
|
+
this.data = this.createEmptyCache();
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
this.data = parsed;
|
|
920
|
+
} catch {
|
|
921
|
+
this.data = this.createEmptyCache();
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
/**
|
|
925
|
+
* Save cache to disk
|
|
926
|
+
*/
|
|
927
|
+
async save() {
|
|
928
|
+
if (!this.data) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
this.data.metadata.lastUpdatedAt = Date.now();
|
|
932
|
+
await mkdir(dirname(this.cachePath), { recursive: true });
|
|
933
|
+
await writeFile(this.cachePath, JSON.stringify(this.data, null, 2));
|
|
934
|
+
}
|
|
935
|
+
/**
|
|
936
|
+
* Look up files in cache
|
|
937
|
+
* Returns which files can use cached findings vs need fresh analysis
|
|
938
|
+
*/
|
|
939
|
+
lookup(files) {
|
|
940
|
+
if (!this.data) {
|
|
941
|
+
return {
|
|
942
|
+
cached: [],
|
|
943
|
+
uncached: files,
|
|
944
|
+
stats: { cachedFiles: 0, uncachedFiles: files.length, cachedFindings: 0 }
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
const cached = [];
|
|
948
|
+
const uncached = [];
|
|
949
|
+
let cachedFindings = 0;
|
|
950
|
+
for (const file of files) {
|
|
951
|
+
const hash = hashFileContent(file.content);
|
|
952
|
+
const entry = this.data.entries[file.path];
|
|
953
|
+
if (entry && entry.contentHash === hash) {
|
|
954
|
+
cached.push({
|
|
955
|
+
file,
|
|
956
|
+
findings: entry.findings.map((f) => this.stripCacheFields(f))
|
|
957
|
+
});
|
|
958
|
+
cachedFindings += entry.findings.length;
|
|
959
|
+
entry.lastAccessedAt = Date.now();
|
|
960
|
+
} else {
|
|
961
|
+
uncached.push(file);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
return {
|
|
965
|
+
cached,
|
|
966
|
+
uncached,
|
|
967
|
+
stats: {
|
|
968
|
+
cachedFiles: cached.length,
|
|
969
|
+
uncachedFiles: uncached.length,
|
|
970
|
+
cachedFindings
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Update cache with new findings
|
|
976
|
+
*/
|
|
977
|
+
update(files, findings) {
|
|
978
|
+
if (!this.data) {
|
|
979
|
+
this.data = this.createEmptyCache();
|
|
980
|
+
}
|
|
981
|
+
const findingsByFile = /* @__PURE__ */ new Map();
|
|
982
|
+
for (const finding of findings) {
|
|
983
|
+
const existing = findingsByFile.get(finding.file) ?? [];
|
|
984
|
+
existing.push(finding);
|
|
985
|
+
findingsByFile.set(finding.file, existing);
|
|
986
|
+
}
|
|
987
|
+
for (const file of files) {
|
|
988
|
+
const hash = hashFileContent(file.content);
|
|
989
|
+
const fileFindings = findingsByFile.get(file.path) ?? [];
|
|
990
|
+
const cachedFindings = fileFindings.map((f) => ({
|
|
991
|
+
...f,
|
|
992
|
+
contentHash: hash,
|
|
993
|
+
cachedAt: Date.now()
|
|
994
|
+
}));
|
|
995
|
+
this.data.entries[file.path] = {
|
|
996
|
+
path: file.path,
|
|
997
|
+
contentHash: hash,
|
|
998
|
+
findings: cachedFindings,
|
|
999
|
+
createdAt: Date.now(),
|
|
1000
|
+
lastAccessedAt: Date.now()
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
this.data.metadata.totalFiles = Object.keys(this.data.entries).length;
|
|
1004
|
+
this.data.metadata.totalFindings = Object.values(this.data.entries).reduce((sum, entry) => sum + entry.findings.length, 0);
|
|
1005
|
+
}
|
|
1006
|
+
/**
|
|
1007
|
+
* Compare new findings with cached to find new vs known issues
|
|
1008
|
+
*/
|
|
1009
|
+
compareIncremental(newFindings, cachedFindings) {
|
|
1010
|
+
const knownSignatures = /* @__PURE__ */ new Set();
|
|
1011
|
+
if (this.data) {
|
|
1012
|
+
for (const entry of Object.values(this.data.entries)) {
|
|
1013
|
+
for (const finding of entry.findings) {
|
|
1014
|
+
knownSignatures.add(findingSignature(finding));
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const newIssues = [];
|
|
1019
|
+
const knownIssues = [];
|
|
1020
|
+
for (const finding of newFindings) {
|
|
1021
|
+
const sig = findingSignature(finding);
|
|
1022
|
+
if (knownSignatures.has(sig)) {
|
|
1023
|
+
knownIssues.push(finding);
|
|
1024
|
+
} else {
|
|
1025
|
+
newIssues.push(finding);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
return {
|
|
1029
|
+
newFindings: newIssues,
|
|
1030
|
+
knownFindings: knownIssues,
|
|
1031
|
+
cachedFindings,
|
|
1032
|
+
stats: {
|
|
1033
|
+
new: newIssues.length,
|
|
1034
|
+
known: knownIssues.length,
|
|
1035
|
+
cached: cachedFindings.length,
|
|
1036
|
+
total: newIssues.length + knownIssues.length + cachedFindings.length
|
|
1037
|
+
}
|
|
1038
|
+
};
|
|
1039
|
+
}
|
|
1040
|
+
/**
|
|
1041
|
+
* Clear cache for specific files or all
|
|
1042
|
+
*/
|
|
1043
|
+
clear(files) {
|
|
1044
|
+
if (!this.data) {
|
|
1045
|
+
return;
|
|
1046
|
+
}
|
|
1047
|
+
if (files) {
|
|
1048
|
+
for (const file of files) {
|
|
1049
|
+
delete this.data.entries[file];
|
|
1050
|
+
}
|
|
1051
|
+
} else {
|
|
1052
|
+
this.data = this.createEmptyCache();
|
|
1053
|
+
}
|
|
1054
|
+
}
|
|
1055
|
+
/**
|
|
1056
|
+
* Get cache statistics
|
|
1057
|
+
*/
|
|
1058
|
+
getStats() {
|
|
1059
|
+
if (!this.data) {
|
|
1060
|
+
return { totalFiles: 0, totalFindings: 0, cacheAge: 0 };
|
|
1061
|
+
}
|
|
1062
|
+
return {
|
|
1063
|
+
totalFiles: this.data.metadata.totalFiles,
|
|
1064
|
+
totalFindings: this.data.metadata.totalFindings,
|
|
1065
|
+
cacheAge: Date.now() - this.data.metadata.createdAt
|
|
1066
|
+
};
|
|
1067
|
+
}
|
|
1068
|
+
createEmptyCache() {
|
|
1069
|
+
return {
|
|
1070
|
+
version: CACHE_VERSION,
|
|
1071
|
+
entries: {},
|
|
1072
|
+
metadata: {
|
|
1073
|
+
createdAt: Date.now(),
|
|
1074
|
+
lastUpdatedAt: Date.now(),
|
|
1075
|
+
totalFindings: 0,
|
|
1076
|
+
totalFiles: 0
|
|
1077
|
+
}
|
|
1078
|
+
};
|
|
1079
|
+
}
|
|
1080
|
+
stripCacheFields(finding) {
|
|
1081
|
+
const { contentHash: _contentHash, cachedAt: _cachedAt, ...rest } = finding;
|
|
1082
|
+
return rest;
|
|
1083
|
+
}
|
|
1084
|
+
};
|
|
1085
|
+
function createFindingsCache(cwd) {
|
|
1086
|
+
return new FindingsCache(cwd);
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// src/orchestrator.ts
|
|
1090
|
+
var ReviewOrchestrator = class {
|
|
1091
|
+
/**
|
|
1092
|
+
* Run code review.
|
|
1093
|
+
*
|
|
1094
|
+
* @param request - Review request
|
|
1095
|
+
* @returns Review result with findings
|
|
1096
|
+
*/
|
|
1097
|
+
async review(request) {
|
|
1098
|
+
const startTime = Date.now();
|
|
1099
|
+
const preset = await loadPreset(request.presetId);
|
|
1100
|
+
const analytics = useAnalytics();
|
|
1101
|
+
analytics?.track("review:started", {
|
|
1102
|
+
mode: request.mode,
|
|
1103
|
+
repoScope: request.repoScope,
|
|
1104
|
+
presetId: request.presetId
|
|
1105
|
+
});
|
|
1106
|
+
const runCtx = {};
|
|
1107
|
+
try {
|
|
1108
|
+
const findings = await this.runAnalysis(request, preset, runCtx);
|
|
1109
|
+
const analyzedFiles = await this.countFiles(request, preset);
|
|
1110
|
+
const metadata = {
|
|
1111
|
+
preset: request.presetId,
|
|
1112
|
+
mode: request.mode,
|
|
1113
|
+
filesReviewed: analyzedFiles,
|
|
1114
|
+
analyzedFiles,
|
|
1115
|
+
heuristicFindings: findings.filter((f) => f.source === "heuristic").length,
|
|
1116
|
+
llmFindings: findings.filter((f) => f.source !== "heuristic").length,
|
|
1117
|
+
totalFindings: findings.length,
|
|
1118
|
+
durationMs: Date.now() - startTime,
|
|
1119
|
+
engines: this.getEnginesUsed(findings)
|
|
1120
|
+
};
|
|
1121
|
+
if (runCtx.llmLiteResult) {
|
|
1122
|
+
const llmMeta = runCtx.llmLiteResult.metadata;
|
|
1123
|
+
Object.assign(metadata, {
|
|
1124
|
+
llmLite: {
|
|
1125
|
+
llmCalls: llmMeta.llmCalls,
|
|
1126
|
+
toolCalls: llmMeta.toolCalls,
|
|
1127
|
+
tokens: llmMeta.tokens,
|
|
1128
|
+
estimatedCost: llmMeta.estimatedCost,
|
|
1129
|
+
verification: llmMeta.verification,
|
|
1130
|
+
timing: llmMeta.timing
|
|
1131
|
+
}
|
|
1132
|
+
});
|
|
1133
|
+
}
|
|
1134
|
+
if (runCtx.incrementalResult || runCtx.cacheLookup) {
|
|
1135
|
+
const incr = runCtx.incrementalResult;
|
|
1136
|
+
const cache = runCtx.cacheLookup;
|
|
1137
|
+
const incrementalMeta = {
|
|
1138
|
+
cachedFiles: cache?.stats.cachedFiles ?? 0,
|
|
1139
|
+
analyzedFiles: cache?.stats.uncachedFiles ?? analyzedFiles,
|
|
1140
|
+
newFindings: incr?.stats.new ?? findings.length,
|
|
1141
|
+
knownFindings: incr?.stats.known ?? 0,
|
|
1142
|
+
cachedFindings: incr?.stats.cached ?? 0
|
|
1143
|
+
};
|
|
1144
|
+
Object.assign(metadata, { incremental: incrementalMeta });
|
|
1145
|
+
}
|
|
1146
|
+
const result = {
|
|
1147
|
+
findings,
|
|
1148
|
+
summary: {
|
|
1149
|
+
total: findings.length,
|
|
1150
|
+
bySeverity: {
|
|
1151
|
+
error: findings.filter((f) => f.severity === "blocker" || f.severity === "high").length,
|
|
1152
|
+
warning: findings.filter((f) => f.severity === "medium").length,
|
|
1153
|
+
info: findings.filter((f) => f.severity === "low" || f.severity === "info").length
|
|
1154
|
+
},
|
|
1155
|
+
byType: this.groupByType(findings)
|
|
1156
|
+
},
|
|
1157
|
+
metadata
|
|
1158
|
+
};
|
|
1159
|
+
analytics?.track("review:completed", {
|
|
1160
|
+
mode: request.mode,
|
|
1161
|
+
findingsCount: findings.length,
|
|
1162
|
+
duration: result.metadata.durationMs
|
|
1163
|
+
});
|
|
1164
|
+
return result;
|
|
1165
|
+
} catch (error) {
|
|
1166
|
+
analytics?.track("review:failed", {
|
|
1167
|
+
mode: request.mode,
|
|
1168
|
+
error: error instanceof Error ? error.message : String(error)
|
|
1169
|
+
});
|
|
1170
|
+
throw error;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Run analysis based on review mode.
|
|
1175
|
+
*/
|
|
1176
|
+
async runAnalysis(request, preset, runCtx) {
|
|
1177
|
+
const mode = request.mode ?? "heuristic";
|
|
1178
|
+
switch (mode) {
|
|
1179
|
+
case "heuristic":
|
|
1180
|
+
return this.runHeuristicAnalysis(request, preset);
|
|
1181
|
+
case "full":
|
|
1182
|
+
return this.runFullAnalysis(request, preset, runCtx);
|
|
1183
|
+
case "llm":
|
|
1184
|
+
return this.runLLMAnalysis(request, preset, runCtx);
|
|
1185
|
+
default:
|
|
1186
|
+
throw new Error(`Unknown review mode: ${mode}`);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
/**
|
|
1190
|
+
* Run heuristic-only analysis (CI mode).
|
|
1191
|
+
*
|
|
1192
|
+
* Fast, deterministic analysis using linters via CLI.
|
|
1193
|
+
* No LLM calls.
|
|
1194
|
+
*/
|
|
1195
|
+
async runHeuristicAnalysis(request, _preset) {
|
|
1196
|
+
const files = request.files?.map((f) => f.path) ?? [];
|
|
1197
|
+
if (files.length === 0) {
|
|
1198
|
+
return [];
|
|
1199
|
+
}
|
|
1200
|
+
const runner = new LinterRunner();
|
|
1201
|
+
const results = await runner.runAll(files);
|
|
1202
|
+
const logger = useLogger();
|
|
1203
|
+
for (const result of results) {
|
|
1204
|
+
if (result.error) {
|
|
1205
|
+
logger?.warn(`[${result.engineId}] ${result.error}`);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
const findings = LinterRunner.collectFindings(results);
|
|
1209
|
+
return deduplicateFindings(findings);
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Run full analysis (heuristic + LLM-Lite).
|
|
1213
|
+
*
|
|
1214
|
+
* Runs heuristic analysis (ESLint, etc.) and LLM-Lite in parallel.
|
|
1215
|
+
* Combines deterministic linting with intelligent code review.
|
|
1216
|
+
* Both use caching for incremental analysis.
|
|
1217
|
+
*/
|
|
1218
|
+
async runFullAnalysis(request, preset, runCtx) {
|
|
1219
|
+
const llm = useLLM({ tier: "medium" });
|
|
1220
|
+
const [heuristicFindings, llmFindings] = await Promise.all([
|
|
1221
|
+
// Heuristic analysis (ESLint, etc.)
|
|
1222
|
+
this.runHeuristicAnalysis(request, preset),
|
|
1223
|
+
// LLM-Lite analysis with caching (if LLM available and files provided)
|
|
1224
|
+
llm && request.files?.length ? this.runLLMAnalysis(request, preset, runCtx) : Promise.resolve([])
|
|
1225
|
+
]);
|
|
1226
|
+
return deduplicateFindings([...heuristicFindings, ...llmFindings]);
|
|
1227
|
+
}
|
|
1228
|
+
/**
|
|
1229
|
+
* Run LLM-Lite analysis (v2) with incremental caching.
|
|
1230
|
+
*
|
|
1231
|
+
* Uses batch tools, diff-based context, and anti-hallucination verification.
|
|
1232
|
+
* Caches findings by file content hash to skip unchanged files.
|
|
1233
|
+
*/
|
|
1234
|
+
async runLLMAnalysis(request, _preset, runCtx) {
|
|
1235
|
+
if (!request.files || request.files.length === 0) {
|
|
1236
|
+
return [];
|
|
1237
|
+
}
|
|
1238
|
+
const cwd = request.cwd ?? process.cwd();
|
|
1239
|
+
const findingsCache = createFindingsCache(cwd);
|
|
1240
|
+
await findingsCache.load();
|
|
1241
|
+
const cacheLookup = findingsCache.lookup(request.files);
|
|
1242
|
+
runCtx.cacheLookup = cacheLookup;
|
|
1243
|
+
const cachedFindings = [];
|
|
1244
|
+
for (const cached of cacheLookup.cached) {
|
|
1245
|
+
cachedFindings.push(...cached.findings);
|
|
1246
|
+
}
|
|
1247
|
+
if (cacheLookup.uncached.length === 0) {
|
|
1248
|
+
runCtx.incrementalResult = {
|
|
1249
|
+
newFindings: [],
|
|
1250
|
+
knownFindings: [],
|
|
1251
|
+
cachedFindings,
|
|
1252
|
+
stats: {
|
|
1253
|
+
new: 0,
|
|
1254
|
+
known: 0,
|
|
1255
|
+
cached: cachedFindings.length,
|
|
1256
|
+
total: cachedFindings.length
|
|
1257
|
+
}
|
|
1258
|
+
};
|
|
1259
|
+
return cachedFindings;
|
|
1260
|
+
}
|
|
1261
|
+
const diffProvider = createDiffProvider(cwd);
|
|
1262
|
+
const result = await runLLMLiteAnalysis({
|
|
1263
|
+
cwd,
|
|
1264
|
+
files: cacheLookup.uncached,
|
|
1265
|
+
taskContext: request.taskContext,
|
|
1266
|
+
repoScope: request.repoScope,
|
|
1267
|
+
diffProvider
|
|
1268
|
+
});
|
|
1269
|
+
runCtx.llmLiteResult = result;
|
|
1270
|
+
const incrementalResult = findingsCache.compareIncremental(
|
|
1271
|
+
result.findings,
|
|
1272
|
+
cachedFindings
|
|
1273
|
+
);
|
|
1274
|
+
runCtx.incrementalResult = incrementalResult;
|
|
1275
|
+
findingsCache.update(cacheLookup.uncached, result.findings);
|
|
1276
|
+
await findingsCache.save();
|
|
1277
|
+
const allFindings = [
|
|
1278
|
+
...incrementalResult.newFindings,
|
|
1279
|
+
...incrementalResult.knownFindings,
|
|
1280
|
+
...incrementalResult.cachedFindings
|
|
1281
|
+
];
|
|
1282
|
+
return deduplicateFindings(allFindings);
|
|
1283
|
+
}
|
|
1284
|
+
/**
|
|
1285
|
+
* Resolve file patterns to absolute paths.
|
|
1286
|
+
*
|
|
1287
|
+
* Note: Files must be provided in request.files.
|
|
1288
|
+
* Pattern resolution should happen at CLI layer using ctx.runtime.fs.glob()
|
|
1289
|
+
*/
|
|
1290
|
+
async resolvePatterns(request, _preset) {
|
|
1291
|
+
if (!request.files || request.files.length === 0) {
|
|
1292
|
+
throw new Error("No files provided for review. Use ctx.runtime.fs.glob() at CLI layer to resolve patterns.");
|
|
1293
|
+
}
|
|
1294
|
+
return request.files.map((f) => f.path);
|
|
1295
|
+
}
|
|
1296
|
+
/**
|
|
1297
|
+
* Count files to be analyzed.
|
|
1298
|
+
*/
|
|
1299
|
+
async countFiles(request, preset) {
|
|
1300
|
+
const files = await this.resolvePatterns(request, preset);
|
|
1301
|
+
return files.length;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Get list of engines that produced findings.
|
|
1305
|
+
*/
|
|
1306
|
+
getEnginesUsed(findings) {
|
|
1307
|
+
const engines = new Set(findings.map((f) => f.engine));
|
|
1308
|
+
return [...engines];
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Group findings by type.
|
|
1312
|
+
*/
|
|
1313
|
+
groupByType(findings) {
|
|
1314
|
+
const groups = {};
|
|
1315
|
+
for (const finding of findings) {
|
|
1316
|
+
const type = finding.type;
|
|
1317
|
+
groups[type] = (groups[type] ?? 0) + 1;
|
|
1318
|
+
}
|
|
1319
|
+
return groups;
|
|
1320
|
+
}
|
|
1321
|
+
/**
|
|
1322
|
+
* Generate cache key for heuristic analysis.
|
|
1323
|
+
*/
|
|
1324
|
+
generateCacheKey(engine, request) {
|
|
1325
|
+
const filePaths = request.files ? request.files.map((f) => f.path).sort().join(",") : "default";
|
|
1326
|
+
const repoScopeKey = request.repoScope ? Array.isArray(request.repoScope) ? request.repoScope.join(",") : String(request.repoScope) : "default";
|
|
1327
|
+
const parts = [
|
|
1328
|
+
"review",
|
|
1329
|
+
engine,
|
|
1330
|
+
repoScopeKey,
|
|
1331
|
+
request.presetId ?? "default",
|
|
1332
|
+
filePaths
|
|
1333
|
+
];
|
|
1334
|
+
return parts.join(":");
|
|
1335
|
+
}
|
|
1336
|
+
};
|
|
1337
|
+
async function runReview(request) {
|
|
1338
|
+
const orchestrator = new ReviewOrchestrator();
|
|
1339
|
+
return orchestrator.review(request);
|
|
1340
|
+
}
|
|
1341
|
+
function parseGitmodules(cwd) {
|
|
1342
|
+
const result = /* @__PURE__ */ new Map();
|
|
1343
|
+
const gitmodulesPath = join(cwd, ".gitmodules");
|
|
1344
|
+
if (!existsSync(gitmodulesPath)) {
|
|
1345
|
+
return result;
|
|
1346
|
+
}
|
|
1347
|
+
try {
|
|
1348
|
+
const content = readFileSync(gitmodulesPath, "utf-8");
|
|
1349
|
+
const pathMatches = content.matchAll(/^\s*path\s*=\s*(.+)$/gm);
|
|
1350
|
+
for (const match of pathMatches) {
|
|
1351
|
+
const relPath = (match[1] ?? "").trim();
|
|
1352
|
+
if (!relPath) {
|
|
1353
|
+
continue;
|
|
1354
|
+
}
|
|
1355
|
+
const name = relPath.split("/").pop() ?? relPath;
|
|
1356
|
+
result.set(name, relPath);
|
|
1357
|
+
}
|
|
1358
|
+
} catch {
|
|
1359
|
+
}
|
|
1360
|
+
return result;
|
|
1361
|
+
}
|
|
1362
|
+
function resolveRepoPath(cwd, repo, submodules) {
|
|
1363
|
+
const direct = join(cwd, repo);
|
|
1364
|
+
if (existsSync(direct)) {
|
|
1365
|
+
return direct;
|
|
1366
|
+
}
|
|
1367
|
+
const relPath = submodules.get(repo);
|
|
1368
|
+
if (relPath) {
|
|
1369
|
+
const full = join(cwd, relPath);
|
|
1370
|
+
if (existsSync(full)) {
|
|
1371
|
+
return full;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
return null;
|
|
1375
|
+
}
|
|
1376
|
+
function resolveRepoRelative(cwd, repo, submodules) {
|
|
1377
|
+
if (existsSync(join(cwd, repo))) {
|
|
1378
|
+
return repo;
|
|
1379
|
+
}
|
|
1380
|
+
const relPath = submodules.get(repo);
|
|
1381
|
+
if (relPath && existsSync(join(cwd, relPath))) {
|
|
1382
|
+
return relPath;
|
|
1383
|
+
}
|
|
1384
|
+
return null;
|
|
1385
|
+
}
|
|
1386
|
+
async function resolveGitScope(options) {
|
|
1387
|
+
const {
|
|
1388
|
+
cwd,
|
|
1389
|
+
repos,
|
|
1390
|
+
includeStaged = true,
|
|
1391
|
+
includeUnstaged = true,
|
|
1392
|
+
includeUntracked = false
|
|
1393
|
+
} = options;
|
|
1394
|
+
const allFiles = [];
|
|
1395
|
+
let stagedCount = 0;
|
|
1396
|
+
let unstagedCount = 0;
|
|
1397
|
+
let untrackedCount = 0;
|
|
1398
|
+
const submodules = parseGitmodules(cwd);
|
|
1399
|
+
for (const repo of repos) {
|
|
1400
|
+
const repoPath = resolveRepoPath(cwd, repo, submodules);
|
|
1401
|
+
const repoRelative = resolveRepoRelative(cwd, repo, submodules) ?? repo;
|
|
1402
|
+
if (!repoPath) {
|
|
1403
|
+
useLogger()?.debug(`[git-scope] Repo not found: ${repo}`);
|
|
1404
|
+
continue;
|
|
1405
|
+
}
|
|
1406
|
+
const gitPath = join(repoPath, ".git");
|
|
1407
|
+
const isNestedRepo = existsSync(gitPath);
|
|
1408
|
+
const gitCwd = isNestedRepo ? repoPath : cwd;
|
|
1409
|
+
try {
|
|
1410
|
+
const git = simpleGit(gitCwd);
|
|
1411
|
+
const status = await git.status();
|
|
1412
|
+
const filePaths = [];
|
|
1413
|
+
if (includeStaged) {
|
|
1414
|
+
const stagedNotDeleted = status.staged.filter((f) => !status.deleted.includes(f));
|
|
1415
|
+
stagedCount += stagedNotDeleted.length;
|
|
1416
|
+
filePaths.push(...stagedNotDeleted);
|
|
1417
|
+
}
|
|
1418
|
+
if (includeUnstaged) {
|
|
1419
|
+
const unstaged = status.modified.filter((f) => !status.staged.includes(f));
|
|
1420
|
+
unstagedCount += unstaged.length;
|
|
1421
|
+
filePaths.push(...unstaged);
|
|
1422
|
+
}
|
|
1423
|
+
if (includeUntracked) {
|
|
1424
|
+
untrackedCount += status.not_added.length;
|
|
1425
|
+
filePaths.push(...status.not_added);
|
|
1426
|
+
}
|
|
1427
|
+
const uniquePaths = [...new Set(filePaths)].filter((f) => !shouldIgnoreFile(f)).filter((f) => isReviewableFile(f));
|
|
1428
|
+
for (const filePath of uniquePaths) {
|
|
1429
|
+
try {
|
|
1430
|
+
const absolutePath = join(gitCwd, filePath);
|
|
1431
|
+
const content = await readFile(absolutePath, "utf-8");
|
|
1432
|
+
const relativePath = isNestedRepo ? `${repoRelative}/${filePath}` : filePath;
|
|
1433
|
+
allFiles.push({
|
|
1434
|
+
path: relativePath,
|
|
1435
|
+
content
|
|
1436
|
+
});
|
|
1437
|
+
} catch {
|
|
1438
|
+
useLogger()?.debug(`[git-scope] Could not read file: ${filePath}`);
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
} catch (error) {
|
|
1442
|
+
useLogger()?.debug(`[git-scope] Git error in ${repo}:`, { error });
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
return {
|
|
1446
|
+
files: allFiles,
|
|
1447
|
+
summary: {
|
|
1448
|
+
repos,
|
|
1449
|
+
staged: stagedCount,
|
|
1450
|
+
unstaged: unstagedCount,
|
|
1451
|
+
untracked: untrackedCount,
|
|
1452
|
+
total: allFiles.length
|
|
1453
|
+
}
|
|
1454
|
+
};
|
|
1455
|
+
}
|
|
1456
|
+
async function discoverRepos(cwd) {
|
|
1457
|
+
const submodules = parseGitmodules(cwd);
|
|
1458
|
+
if (submodules.size > 0) {
|
|
1459
|
+
return [...submodules.values()].filter(
|
|
1460
|
+
(relPath) => existsSync(join(cwd, relPath, ".git"))
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
const repos = [];
|
|
1464
|
+
try {
|
|
1465
|
+
const entries = readdirSync(cwd);
|
|
1466
|
+
for (const entry of entries) {
|
|
1467
|
+
if (entry.startsWith(".") || entry === "node_modules") {
|
|
1468
|
+
continue;
|
|
1469
|
+
}
|
|
1470
|
+
try {
|
|
1471
|
+
const entryPath = join(cwd, entry);
|
|
1472
|
+
if (statSync(entryPath).isDirectory() && existsSync(join(entryPath, ".git"))) {
|
|
1473
|
+
repos.push(entry);
|
|
1474
|
+
}
|
|
1475
|
+
} catch {
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
} catch {
|
|
1479
|
+
}
|
|
1480
|
+
return repos;
|
|
1481
|
+
}
|
|
1482
|
+
async function getReposWithChanges(cwd) {
|
|
1483
|
+
const allRepos = await discoverRepos(cwd);
|
|
1484
|
+
const reposWithChanges = [];
|
|
1485
|
+
for (const repo of allRepos) {
|
|
1486
|
+
const repoPath = join(cwd, repo);
|
|
1487
|
+
try {
|
|
1488
|
+
const git = simpleGit(repoPath);
|
|
1489
|
+
const status = await git.status();
|
|
1490
|
+
const hasChanges = status.staged.length > 0 || status.modified.length > 0 || status.deleted.length > 0 || status.not_added.length > 0;
|
|
1491
|
+
if (hasChanges) {
|
|
1492
|
+
reposWithChanges.push(repo);
|
|
1493
|
+
}
|
|
1494
|
+
} catch {
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
return reposWithChanges;
|
|
1498
|
+
}
|
|
1499
|
+
function shouldIgnoreFile(file) {
|
|
1500
|
+
const ignoredPaths = [
|
|
1501
|
+
"node_modules/",
|
|
1502
|
+
".git/",
|
|
1503
|
+
"dist/",
|
|
1504
|
+
"build/",
|
|
1505
|
+
".next/",
|
|
1506
|
+
".turbo/",
|
|
1507
|
+
"coverage/",
|
|
1508
|
+
".cache/",
|
|
1509
|
+
".temp/",
|
|
1510
|
+
"tmp/",
|
|
1511
|
+
"pnpm-lock.yaml",
|
|
1512
|
+
"package-lock.json",
|
|
1513
|
+
"yarn.lock"
|
|
1514
|
+
];
|
|
1515
|
+
return ignoredPaths.some((path2) => file.includes(path2));
|
|
1516
|
+
}
|
|
1517
|
+
function isReviewableFile(file) {
|
|
1518
|
+
const reviewableExtensions = [
|
|
1519
|
+
".ts",
|
|
1520
|
+
".tsx",
|
|
1521
|
+
".js",
|
|
1522
|
+
".jsx",
|
|
1523
|
+
".py",
|
|
1524
|
+
".go",
|
|
1525
|
+
".rs",
|
|
1526
|
+
".java",
|
|
1527
|
+
".kt",
|
|
1528
|
+
".swift",
|
|
1529
|
+
".rb",
|
|
1530
|
+
".php",
|
|
1531
|
+
".c",
|
|
1532
|
+
".cpp",
|
|
1533
|
+
".h",
|
|
1534
|
+
".hpp",
|
|
1535
|
+
".cs"
|
|
1536
|
+
];
|
|
1537
|
+
return reviewableExtensions.some((ext) => file.endsWith(ext));
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
export { DiffProvider, FindingsCache, PresetLoader, ReviewOrchestrator, builtinPresets, createDiffProvider, createFindingsCache, discoverRepos, findingSignature, getPresetLoader, getReposWithChanges, hashFileContent, loadPreset, resolveGitScope, runReview };
|
|
1541
|
+
//# sourceMappingURL=index.js.map
|
|
1542
|
+
//# sourceMappingURL=index.js.map
|