jstar-reviewer 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,49 +1,49 @@
1
- # J-Star Code Reviewer
2
-
3
- Local-first, context-aware code review with a deterministic security audit layer.
4
-
5
- ## What it does
6
-
7
- - Builds a local vector index for repo-aware reviews
8
- - Runs hybrid reviews with deterministic checks plus LLM analysis
9
- - Produces machine-readable review output for automation
10
- - Runs a standalone deterministic security audit with markdown and JSON reports
11
-
12
- ## Quick start
13
-
14
- ```bash
15
- pnpm install
16
- pnpm run index:init
17
- git add .
18
- pnpm run review
19
- pnpm run audit
20
- ```
21
-
22
- Review output:
23
- - `.jstar/last-review.md`
24
- - `.jstar/session.json`
25
-
26
- Audit output:
27
- - `.jstar/audit_report.md`
28
- - `.jstar/audit_report.json`
29
-
30
- ## CLI
31
-
32
- ```bash
33
- jstar setup
34
- jstar init
35
- jstar review
36
- jstar review --pr
37
- jstar audit
38
- jstar audit --path src
39
- jstar audit --json
40
- jstar chat --headless
41
- ```
42
-
43
- ## Notes
44
-
45
- - `review` requires `GEMINI_API_KEY` and `GROQ_API_KEY`
46
- - `audit` and `detect` do not require model keys
47
- - deterministic audit ignores live in `.jstar/audit-ignore.json`
48
-
49
- See [ONBOARDING.md](./ONBOARDING.md) and [docs/features/cli-commands.md](./docs/features/cli-commands.md) for details.
1
+ # J-Star Code Reviewer
2
+
3
+ Local-first, context-aware code review with a deterministic security audit layer.
4
+
5
+ ## What it does
6
+
7
+ - Builds a local vector index for repo-aware reviews
8
+ - Runs hybrid reviews with deterministic checks plus LLM analysis
9
+ - Produces machine-readable review output for automation
10
+ - Runs a standalone deterministic security audit with markdown and JSON reports
11
+
12
+ ## Quick start
13
+
14
+ ```bash
15
+ pnpm install
16
+ pnpm run index:init
17
+ git add .
18
+ pnpm run review
19
+ pnpm run audit
20
+ ```
21
+
22
+ Review output:
23
+ - `.jstar/last-review.md`
24
+ - `.jstar/session.json`
25
+
26
+ Audit output:
27
+ - `.jstar/audit_report.md`
28
+ - `.jstar/audit_report.json`
29
+
30
+ ## CLI
31
+
32
+ ```bash
33
+ jstar setup
34
+ jstar init
35
+ jstar review
36
+ jstar review --pr
37
+ jstar audit
38
+ jstar audit --path src
39
+ jstar audit --json
40
+ jstar chat --headless
41
+ ```
42
+
43
+ ## Notes
44
+
45
+ - `review` requires `GEMINI_API_KEY` and `GROQ_API_KEY`
46
+ - `audit` and `detect` do not require model keys
47
+ - deterministic audit ignores live in `.jstar/audit-ignore.json`
48
+
49
+ See [ONBOARDING.md](./ONBOARDING.md) and [docs/features/cli-commands.md](./docs/features/cli-commands.md) for details.
package/bin/jstar.js CHANGED
@@ -29,7 +29,7 @@ function log(msg) {
29
29
 
30
30
  function printHelp() {
31
31
  log(`
32
- ${COLORS.bold}🌟 J-Star Reviewer v3.0.0${COLORS.reset}
32
+ ${COLORS.bold}🌟 J-Star Reviewer v3.0.1${COLORS.reset}
33
33
 
34
34
  ${COLORS.dim}AI-powered code review with local embeddings${COLORS.reset}
35
35
 
@@ -162,12 +162,12 @@ function runScript(scriptName) {
162
162
  });
163
163
  }
164
164
 
165
- const REQUIRED_ENV_VARS = {
166
- 'GEMINI_API_KEY': '# Required: Gemini API key (or GOOGLE_API_KEY)\nGEMINI_API_KEY=your_gemini_api_key_here',
167
- 'GROQ_API_KEY': '# Required: Groq API key for LLM reviews\nGROQ_API_KEY=your_groq_api_key_here',
168
- 'GEMINI_EMBEDDING_MODEL': '# Optional: Override the embedding model\n# GEMINI_EMBEDDING_MODEL=gemini-embedding-001',
169
- 'REVIEW_MODEL_NAME': '# Optional: Override the default model\n# REVIEW_MODEL_NAME=moonshotai/kimi-k2-instruct-0905'
170
- };
165
+ const REQUIRED_ENV_VARS = {
166
+ 'GEMINI_API_KEY': '# Required: Gemini API key (or GOOGLE_API_KEY)\nGEMINI_API_KEY=your_gemini_api_key_here',
167
+ 'GROQ_API_KEY': '# Required: Groq API key for LLM reviews\nGROQ_API_KEY=your_groq_api_key_here',
168
+ 'GEMINI_EMBEDDING_MODEL': '# Optional: Override the embedding model\n# GEMINI_EMBEDDING_MODEL=gemini-embedding-001',
169
+ 'REVIEW_MODEL_NAME': '# Optional: Override the default model\n# REVIEW_MODEL_NAME=moonshotai/kimi-k2-instruct-0905'
170
+ };
171
171
 
172
172
  function createSetupFiles() {
173
173
  const cwd = process.cwd();
@@ -44,19 +44,237 @@ exports.runDeterministicAudit = runDeterministicAudit;
44
44
  const fs = __importStar(require("fs"));
45
45
  const path = __importStar(require("path"));
46
46
  const simple_git_1 = __importDefault(require("simple-git"));
47
+ const typescript_1 = __importDefault(require("typescript"));
48
+ const logger_1 = require("../utils/logger");
47
49
  const project_1 = require("./project");
48
50
  exports.RULES_VERSION = "security-audit-v1";
51
+ function readStringLiteral(source, start) {
52
+ const quote = source[start];
53
+ if (quote !== '"' && quote !== "'") {
54
+ return null;
55
+ }
56
+ let value = "";
57
+ for (let index = start + 1; index < source.length; index++) {
58
+ const char = source[index];
59
+ if (char === "\r" || char === "\n") {
60
+ return null;
61
+ }
62
+ if (char === "\\") {
63
+ if (index + 1 >= source.length) {
64
+ return null;
65
+ }
66
+ value += source.slice(index, index + 2);
67
+ index++;
68
+ continue;
69
+ }
70
+ if (char === quote) {
71
+ return { value, end: index + 1 };
72
+ }
73
+ value += char;
74
+ }
75
+ return null;
76
+ }
77
+ function isStatementTerminated(source, index) {
78
+ let cursor = index;
79
+ while (cursor < source.length) {
80
+ const char = source[cursor];
81
+ if (char === " " || char === "\t" || char === "\v" || char === "\f") {
82
+ cursor++;
83
+ continue;
84
+ }
85
+ if (char === ";") {
86
+ return true;
87
+ }
88
+ if (source.startsWith("//", cursor)) {
89
+ return true;
90
+ }
91
+ if (source.startsWith("/*", cursor)) {
92
+ const commentEnd = source.indexOf("*/", cursor + 2);
93
+ if (commentEnd === -1) {
94
+ return false;
95
+ }
96
+ cursor = commentEnd + 2;
97
+ continue;
98
+ }
99
+ return false;
100
+ }
101
+ return true;
102
+ }
103
+ function isUseClientDirectiveStatement(statement) {
104
+ const directive = readStringLiteral(statement, 0);
105
+ return directive?.value === "use client" && isStatementTerminated(statement, directive.end);
106
+ }
107
+ function collectStatementLines(content) {
108
+ const statements = [];
109
+ const lines = content.split(/\r?\n/);
110
+ let inBlockComment = false;
111
+ nextLine: for (let index = 0; index < lines.length; index++) {
112
+ const rawLine = lines[index];
113
+ const line = index === 0 ? rawLine.replace(/^\uFEFF/, "") : rawLine;
114
+ if (!inBlockComment && index === 0 && line.startsWith("#!")) {
115
+ continue;
116
+ }
117
+ let cursor = 0;
118
+ while (cursor < line.length) {
119
+ if (inBlockComment) {
120
+ const commentEnd = line.indexOf("*/", cursor);
121
+ if (commentEnd === -1) {
122
+ continue nextLine;
123
+ }
124
+ inBlockComment = false;
125
+ cursor = commentEnd + 2;
126
+ continue;
127
+ }
128
+ const char = line[cursor];
129
+ if (char === " " || char === "\t" || char === "\v" || char === "\f") {
130
+ cursor++;
131
+ continue;
132
+ }
133
+ if (line.startsWith("//", cursor)) {
134
+ continue nextLine;
135
+ }
136
+ if (line.startsWith("/*", cursor)) {
137
+ inBlockComment = true;
138
+ cursor += 2;
139
+ continue;
140
+ }
141
+ statements.push({
142
+ line: index + 1,
143
+ text: line.slice(cursor),
144
+ });
145
+ continue nextLine;
146
+ }
147
+ }
148
+ return statements;
149
+ }
150
+ function hasUseClientAsFirstStatement(content) {
151
+ const firstStatement = collectStatementLines(content)[0];
152
+ return firstStatement ? isUseClientDirectiveStatement(firstStatement.text) : false;
153
+ }
154
+ function findUseClientDirectiveLine(content) {
155
+ return collectStatementLines(content).find((statement) => isUseClientDirectiveStatement(statement.text))?.line;
156
+ }
157
+ function getLanguageVariant(normalizedPath) {
158
+ return /\.(?:[jt]sx)$/i.test(normalizedPath) ? typescript_1.default.LanguageVariant.JSX : typescript_1.default.LanguageVariant.Standard;
159
+ }
160
+ function getScriptKind(normalizedPath) {
161
+ if (/\.tsx$/i.test(normalizedPath)) {
162
+ return typescript_1.default.ScriptKind.TSX;
163
+ }
164
+ if (/\.jsx$/i.test(normalizedPath)) {
165
+ return typescript_1.default.ScriptKind.JSX;
166
+ }
167
+ if (/\.js$/i.test(normalizedPath)) {
168
+ return typescript_1.default.ScriptKind.JS;
169
+ }
170
+ return typescript_1.default.ScriptKind.TS;
171
+ }
172
+ function maskTriviaText(text) {
173
+ return text.replace(/[^\r\n]/g, " ");
174
+ }
175
+ function shouldMaskInCodeView(kind) {
176
+ return (kind === typescript_1.default.SyntaxKind.SingleLineCommentTrivia ||
177
+ kind === typescript_1.default.SyntaxKind.MultiLineCommentTrivia ||
178
+ kind === typescript_1.default.SyntaxKind.ShebangTrivia ||
179
+ kind === typescript_1.default.SyntaxKind.StringLiteral ||
180
+ kind === typescript_1.default.SyntaxKind.NoSubstitutionTemplateLiteral ||
181
+ kind === typescript_1.default.SyntaxKind.TemplateHead ||
182
+ kind === typescript_1.default.SyntaxKind.TemplateMiddle ||
183
+ kind === typescript_1.default.SyntaxKind.TemplateTail ||
184
+ kind === typescript_1.default.SyntaxKind.JsxText ||
185
+ kind === typescript_1.default.SyntaxKind.JsxTextAllWhiteSpaces ||
186
+ kind === typescript_1.default.SyntaxKind.RegularExpressionLiteral);
187
+ }
188
+ function buildCodeView(content, normalizedPath) {
189
+ const scanner = typescript_1.default.createScanner(typescript_1.default.ScriptTarget.Latest, false, getLanguageVariant(normalizedPath), content);
190
+ let masked = "";
191
+ let cursor = 0;
192
+ for (let token = scanner.scan(); token !== typescript_1.default.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
193
+ const tokenStart = scanner.getTokenPos();
194
+ const tokenEnd = scanner.getTextPos();
195
+ const tokenText = scanner.getTokenText();
196
+ if (tokenStart > cursor) {
197
+ masked += content.slice(cursor, tokenStart);
198
+ }
199
+ masked += shouldMaskInCodeView(token) ? maskTriviaText(tokenText) : tokenText;
200
+ cursor = tokenEnd;
201
+ }
202
+ if (cursor < content.length) {
203
+ masked += content.slice(cursor);
204
+ }
205
+ return masked;
206
+ }
207
+ function isTriviaToken(kind) {
208
+ return (kind === typescript_1.default.SyntaxKind.SingleLineCommentTrivia ||
209
+ kind === typescript_1.default.SyntaxKind.MultiLineCommentTrivia ||
210
+ kind === typescript_1.default.SyntaxKind.NewLineTrivia ||
211
+ kind === typescript_1.default.SyntaxKind.WhitespaceTrivia ||
212
+ kind === typescript_1.default.SyntaxKind.ShebangTrivia);
213
+ }
214
+ function collectSignificantTokens(content, normalizedPath) {
215
+ const sourceFile = typescript_1.default.createSourceFile(normalizedPath, content, typescript_1.default.ScriptTarget.Latest, false, getScriptKind(normalizedPath));
216
+ const scanner = typescript_1.default.createScanner(typescript_1.default.ScriptTarget.Latest, false, getLanguageVariant(normalizedPath), content);
217
+ const tokens = [];
218
+ for (let kind = scanner.scan(); kind !== typescript_1.default.SyntaxKind.EndOfFileToken; kind = scanner.scan()) {
219
+ if (isTriviaToken(kind)) {
220
+ continue;
221
+ }
222
+ tokens.push({
223
+ kind,
224
+ line: typescript_1.default.getLineAndCharacterOfPosition(sourceFile, scanner.getTokenPos()).line + 1,
225
+ text: scanner.getTokenText(),
226
+ });
227
+ }
228
+ return tokens;
229
+ }
230
+ const SECRET_NAME_PATTERN = /^(?:api[_-]?key|password|(?:access|auth|bearer|client|refresh|session)?[_-]?(?:secret|token))$/i;
231
+ function normalizeTokenName(token) {
232
+ if (token.kind === typescript_1.default.SyntaxKind.Identifier) {
233
+ return token.text;
234
+ }
235
+ if (token.kind === typescript_1.default.SyntaxKind.StringLiteral) {
236
+ return token.text.slice(1, -1);
237
+ }
238
+ return null;
239
+ }
240
+ function readStringTokenValue(token) {
241
+ if (token.kind === typescript_1.default.SyntaxKind.StringLiteral || token.kind === typescript_1.default.SyntaxKind.NoSubstitutionTemplateLiteral) {
242
+ return token.text.slice(1, -1);
243
+ }
244
+ return null;
245
+ }
246
+ function scanHardcodedSecrets(content, normalizedPath) {
247
+ const findings = [];
248
+ const tokens = collectSignificantTokens(content, normalizedPath);
249
+ for (let index = 0; index < tokens.length - 2; index++) {
250
+ const token = tokens[index];
251
+ const candidateName = normalizeTokenName(token);
252
+ if (!candidateName || !SECRET_NAME_PATTERN.test(candidateName)) {
253
+ continue;
254
+ }
255
+ const operator = tokens[index + 1];
256
+ if (operator.kind !== typescript_1.default.SyntaxKind.EqualsToken && operator.kind !== typescript_1.default.SyntaxKind.ColonToken) {
257
+ continue;
258
+ }
259
+ const value = readStringTokenValue(tokens[index + 2]);
260
+ if (!value || value.length < 10) {
261
+ continue;
262
+ }
263
+ findings.push({
264
+ ruleId: "SEC-001",
265
+ title: "Hardcoded secret in source",
266
+ severity: "CRITICAL",
267
+ category: "SECURITY",
268
+ file: normalizedPath,
269
+ line: token.line,
270
+ message: "Possible hardcoded credential detected in source.",
271
+ recommendation: "Move the credential to environment configuration and rotate the exposed secret.",
272
+ source: "deterministic",
273
+ });
274
+ }
275
+ return findings;
276
+ }
49
277
  const LINE_RULES = [
50
- {
51
- id: "SEC-001",
52
- title: "Hardcoded secret in source",
53
- severity: "CRITICAL",
54
- category: "SECURITY",
55
- recommendation: "Move the credential to environment configuration and rotate the exposed secret.",
56
- pattern: /(api[_-]?key|secret|password|token)\s*[:=]\s*['"`][A-Za-z0-9._-]{10,}['"`]/i,
57
- excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
58
- buildMessage: () => "Possible hardcoded credential detected in source.",
59
- },
60
278
  {
61
279
  id: "SEC-002",
62
280
  title: "Dynamic code execution",
@@ -66,6 +284,7 @@ const LINE_RULES = [
66
284
  pattern: /\b(?:eval|Function)\s*\(/,
67
285
  excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
68
286
  buildMessage: () => "Dynamic code execution can allow arbitrary code paths and should be avoided.",
287
+ sourceView: "code",
69
288
  },
70
289
  {
71
290
  id: "SEC-003",
@@ -76,6 +295,7 @@ const LINE_RULES = [
76
295
  pattern: /\b(?:\$queryRawUnsafe|\$executeRawUnsafe|queryRawUnsafe|executeRawUnsafe)\s*\(/,
77
296
  excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
78
297
  buildMessage: () => "Unsafe raw SQL helper detected; untrusted input can reach the database without parameterization.",
298
+ sourceView: "code",
79
299
  },
80
300
  {
81
301
  id: "SEC-004",
@@ -86,6 +306,7 @@ const LINE_RULES = [
86
306
  pattern: /dangerouslySetInnerHTML\s*=\s*\{\{/,
87
307
  excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
88
308
  buildMessage: () => "Raw HTML injection sink detected.",
309
+ sourceView: "code",
89
310
  },
90
311
  {
91
312
  id: "QLT-001",
@@ -96,6 +317,7 @@ const LINE_RULES = [
96
317
  pattern: /console\.log\(/,
97
318
  excludePattern: /(bin\/jstar\.js|scripts\/utils\/logger\.ts|setup\.js|test\/)/i,
98
319
  buildMessage: () => "console.log left in source can leak noisy or sensitive runtime details.",
320
+ sourceView: "code",
99
321
  },
100
322
  {
101
323
  id: "QLT-002",
@@ -113,15 +335,24 @@ const FILE_RULES = [
113
335
  title: '"use client" is not the first statement',
114
336
  severity: "HIGH",
115
337
  category: "LOGIC",
116
- recommendation: 'Move the "use client" directive to the top of the module before imports or comments.',
338
+ recommendation: 'Move the "use client" directive above imports and executable statements so it stays the first statement in the module.',
117
339
  filePattern: /\.tsx?$/i,
118
340
  excludePattern: /(scripts|test)\//i,
119
- test: /^(?!(?:\s*|(?:\/\/[^\n]*\n)|(?:\/\*[\s\S]*?\*\/))*['"]use client['"]).*['"]use client['"]/s,
120
- message: 'Next.js requires "use client" to appear before any other statement in the file.',
121
- line: 1,
341
+ test: (content) => Boolean(findUseClientDirectiveLine(content)) && !hasUseClientAsFirstStatement(content),
342
+ message: 'Next.js requires "use client" to be the first statement in the file.',
343
+ line: (content) => findUseClientDirectiveLine(content) ?? 1,
122
344
  },
123
345
  ];
124
346
  const CUSTOM_RULES = [
347
+ {
348
+ id: "SEC-001",
349
+ title: "Hardcoded secret in source",
350
+ severity: "CRITICAL",
351
+ category: "SECURITY",
352
+ recommendation: "Move the credential to environment configuration and rotate the exposed secret.",
353
+ excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
354
+ scan: (content, normalizedPath) => scanHardcodedSecrets(content, normalizedPath),
355
+ },
125
356
  {
126
357
  id: "SEC-005",
127
358
  title: "Server env var referenced in client module",
@@ -131,38 +362,39 @@ const CUSTOM_RULES = [
131
362
  filePattern: /\.tsx?$/i,
132
363
  excludePattern: /(^|\/)(test|tests|fixtures?|mocks?|spec)\//i,
133
364
  scan: (content, normalizedPath) => {
134
- if (!/^\s*['"]use client['"]/m.test(content)) {
365
+ if (!hasUseClientAsFirstStatement(content)) {
135
366
  return [];
136
367
  }
137
368
  const findings = [];
138
- const lines = content.split("\n");
369
+ const codeView = buildCodeView(content, normalizedPath);
370
+ const sourceFile = typescript_1.default.createSourceFile(normalizedPath, content, typescript_1.default.ScriptTarget.Latest, false, getScriptKind(normalizedPath));
139
371
  const envPattern = /process\.env\.([A-Z0-9_]+)/g;
140
- lines.forEach((line, index) => {
141
- let match;
142
- while ((match = envPattern.exec(line)) !== null) {
143
- const envName = match[1];
144
- if (envName.startsWith("NEXT_PUBLIC_")) {
145
- continue;
146
- }
147
- findings.push({
148
- ruleId: "SEC-005",
149
- title: "Server env var referenced in client module",
150
- severity: "CRITICAL",
151
- category: "SECURITY",
152
- file: normalizedPath,
153
- line: index + 1,
154
- message: `Client component references server-only environment variable "${envName}".`,
155
- recommendation: "Move the access to a server-only boundary or expose a safe NEXT_PUBLIC_ value instead.",
156
- source: "deterministic",
157
- });
372
+ let match;
373
+ envPattern.lastIndex = 0;
374
+ while ((match = envPattern.exec(codeView)) !== null) {
375
+ const envName = match[1];
376
+ if (envName.startsWith("NEXT_PUBLIC_")) {
377
+ continue;
158
378
  }
159
- });
379
+ findings.push({
380
+ ruleId: "SEC-005",
381
+ title: "Server env var referenced in client module",
382
+ severity: "CRITICAL",
383
+ category: "SECURITY",
384
+ file: normalizedPath,
385
+ line: typescript_1.default.getLineAndCharacterOfPosition(sourceFile, match.index).line + 1,
386
+ message: `Client component references server-only environment variable "${envName}".`,
387
+ recommendation: "Move the access to a server-only boundary or expose a safe NEXT_PUBLIC_ value instead.",
388
+ source: "deterministic",
389
+ });
390
+ }
160
391
  return findings;
161
392
  },
162
393
  },
163
394
  ];
164
395
  const AUDIT_IGNORE_FILE = path.join(".jstar", "audit-ignore.json");
165
396
  const REQUIRED_GITIGNORE_PATTERNS = [".env", ".env.local", "node_modules", "*.pem", "*.key"];
397
+ const MAX_AUDIT_FILE_SIZE_BYTES = 1024 * 1024;
166
398
  const SEVERITY_RANK = {
167
399
  CRITICAL: 0,
168
400
  HIGH: 1,
@@ -221,12 +453,15 @@ function mapAuditSeverityToReviewSeverity(severity) {
221
453
  }
222
454
  function scanFileContent(content, normalizedPath) {
223
455
  const findings = [];
224
- const lines = content.split("\n");
456
+ const rawLines = content.split(/\r?\n/);
457
+ const codeLines = buildCodeView(content, normalizedPath).split(/\r?\n/);
225
458
  for (const rule of LINE_RULES) {
226
459
  if (!shouldApplyRule(rule, normalizedPath)) {
227
460
  continue;
228
461
  }
462
+ const lines = rule.sourceView === "code" ? codeLines : rawLines;
229
463
  lines.forEach((line, index) => {
464
+ rule.pattern.lastIndex = 0;
230
465
  if (!rule.pattern.test(line)) {
231
466
  return;
232
467
  }
@@ -247,16 +482,17 @@ function scanFileContent(content, normalizedPath) {
247
482
  if (!shouldApplyRule(rule, normalizedPath)) {
248
483
  continue;
249
484
  }
250
- if (!rule.test.test(content)) {
485
+ if (!rule.test(content)) {
251
486
  continue;
252
487
  }
488
+ const line = typeof rule.line === "function" ? rule.line(content) : rule.line;
253
489
  findings.push({
254
490
  ruleId: rule.id,
255
491
  title: rule.title,
256
492
  severity: rule.severity,
257
493
  category: rule.category,
258
494
  file: normalizedPath,
259
- line: rule.line,
495
+ line,
260
496
  message: rule.message,
261
497
  recommendation: rule.recommendation,
262
498
  source: "deterministic",
@@ -381,6 +617,7 @@ async function runRepositoryChecks(cwd) {
381
617
  });
382
618
  }
383
619
  catch {
620
+ logger_1.Logger.warn("Repository checks skipped: unable to access git metadata.");
384
621
  // Repository checks are best-effort outside git worktrees.
385
622
  }
386
623
  return findings;
@@ -396,12 +633,22 @@ async function runDeterministicAudit(options) {
396
633
  .filter((filePath) => (0, project_1.isCodeFile)(filePath))
397
634
  .sort((left, right) => left.localeCompare(right));
398
635
  const rawFindings = [];
636
+ let scannedFiles = 0;
399
637
  for (const filePath of uniqueFiles) {
400
638
  const absolutePath = path.resolve(cwd, filePath);
401
- if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
639
+ if (!fs.existsSync(absolutePath)) {
640
+ continue;
641
+ }
642
+ const fileStats = fs.statSync(absolutePath);
643
+ if (!fileStats.isFile()) {
644
+ continue;
645
+ }
646
+ if (fileStats.size > MAX_AUDIT_FILE_SIZE_BYTES) {
647
+ logger_1.Logger.warn(`Skipping deterministic audit for "${filePath}" because it exceeds ${MAX_AUDIT_FILE_SIZE_BYTES} bytes.`);
402
648
  continue;
403
649
  }
404
650
  const content = fs.readFileSync(absolutePath, "utf-8");
651
+ scannedFiles++;
405
652
  rawFindings.push(...scanFileContent(content, filePath));
406
653
  }
407
654
  if (includeRepositoryChecks) {
@@ -410,7 +657,7 @@ async function runDeterministicAudit(options) {
410
657
  const ignores = loadAuditIgnores(cwd);
411
658
  const { findings, ignoredFindings } = applyIgnores(sortFindings(rawFindings), ignores);
412
659
  const summary = {
413
- filesScanned: uniqueFiles.length,
660
+ filesScanned: scannedFiles,
414
661
  findings: findings.length,
415
662
  critical: findings.filter((finding) => finding.severity === "CRITICAL").length,
416
663
  high: findings.filter((finding) => finding.severity === "HIGH").length,
@@ -0,0 +1,76 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.severityMax = severityMax;
4
+ exports.mergeFindings = mergeFindings;
5
+ const SEVERITY_RANK = {
6
+ P0_CRITICAL: 0,
7
+ P1_HIGH: 1,
8
+ P2_MEDIUM: 2,
9
+ LGTM: 3,
10
+ };
11
+ function severityMax(left, right) {
12
+ return SEVERITY_RANK[left] <= SEVERITY_RANK[right] ? left : right;
13
+ }
14
+ function buildIssueKey(issue) {
15
+ return `${issue.line ?? 0}:${issue.title.trim().toLowerCase()}`;
16
+ }
17
+ function issuePriority(issue) {
18
+ return (issue.source === "deterministic" ? 10 : 0) + (issue.confidenceScore ?? 0);
19
+ }
20
+ function mergeIssue(existing, incoming) {
21
+ const preferred = issuePriority(existing) >= issuePriority(incoming) ? existing : incoming;
22
+ const fallback = preferred === existing ? incoming : existing;
23
+ const confidenceScore = Math.max(existing.confidenceScore ?? 0, incoming.confidenceScore ?? 0);
24
+ return {
25
+ ...fallback,
26
+ ...preferred,
27
+ description: preferred.description.length >= fallback.description.length ? preferred.description : fallback.description,
28
+ fixPrompt: preferred.fixPrompt.length >= fallback.fixPrompt.length ? preferred.fixPrompt : fallback.fixPrompt,
29
+ confidenceScore: confidenceScore > 0 ? confidenceScore : undefined,
30
+ ruleId: preferred.ruleId ?? fallback.ruleId,
31
+ source: preferred.source ?? fallback.source,
32
+ status: preferred.status ?? fallback.status,
33
+ };
34
+ }
35
+ function dedupeIssues(issues) {
36
+ const deduped = new Map();
37
+ issues.forEach((issue) => {
38
+ const key = buildIssueKey(issue);
39
+ const existing = deduped.get(key);
40
+ if (!existing) {
41
+ deduped.set(key, { ...issue });
42
+ return;
43
+ }
44
+ deduped.set(key, mergeIssue(existing, issue));
45
+ });
46
+ return [...deduped.values()];
47
+ }
48
+ function mergeFindings(primary, secondary) {
49
+ const grouped = new Map();
50
+ const insert = (finding) => {
51
+ const existing = grouped.get(finding.file);
52
+ if (!existing) {
53
+ grouped.set(finding.file, {
54
+ ...finding,
55
+ issues: dedupeIssues(finding.issues),
56
+ });
57
+ return;
58
+ }
59
+ existing.severity = severityMax(existing.severity, finding.severity);
60
+ existing.issues = dedupeIssues([...existing.issues, ...finding.issues]);
61
+ };
62
+ primary.forEach(insert);
63
+ secondary.forEach(insert);
64
+ return [...grouped.values()]
65
+ .map((finding) => ({
66
+ ...finding,
67
+ issues: finding.issues.sort((left, right) => {
68
+ const lineDelta = (left.line ?? 0) - (right.line ?? 0);
69
+ if (lineDelta !== 0) {
70
+ return lineDelta;
71
+ }
72
+ return left.title.localeCompare(right.title);
73
+ }),
74
+ }))
75
+ .sort((left, right) => left.file.localeCompare(right.file));
76
+ }