codeant-cli 0.5.8 → 0.5.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/findings.md CHANGED
@@ -49,9 +49,9 @@ codeant findings repo \
49
49
  --output codeant.sarif
50
50
  ```
51
51
 
52
- Supported repo types are `sast`, `sca`, `secrets`, `iac`, `dead_code`, `sbom`, `anti_patterns`, `docstring`, and `complex_functions`. Use `--types all` for all of them. Formats are `json`, `sarif`, `csv`, `md`, and `table`; JSON is the default.
52
+ Supported repo types are `sast`, `sca`, `secrets`, `iac`, `dead_code`, `duplicate_code`, `sbom`, `anti_patterns`, `docstring`, and `complex_functions`. Use `--types all` for all of them. Formats are `json`, `sarif`, `csv`, `md`, and `table`; JSON is the default.
53
53
 
54
- Use `--filter-dismissed` to exclude dismissed findings and `--no-false-positives` to exclude false positives. `--path`, `--check`, `--limit`, and `--offset` support agent-friendly filtering and pagination.
54
+ Use `--filter-dismissed` to exclude dismissed findings and `--no-false-positives` to exclude false positives, including ones users marked in the app. Without those flags, such findings are returned with `metadata.dismissed` or `metadata.false_positive`. `--path`, `--check`, `--limit`, and `--offset` support agent-friendly filtering and pagination.
55
55
 
56
56
  ## Organization Hotlist findings
57
57
 
package/mcp.md CHANGED
@@ -15,7 +15,8 @@ The CodeAnt CLI ships an MCP (Model Context Protocol) server that exposes CodeAn
15
15
  | `codeant_scans_history` | read | Recent scan runs for a repo. |
16
16
  | `codeant_scans_get` | read | Severity/category summary for one scan. |
17
17
  | `codeant_scans_results` | read | Full findings (SAST, SCA, secrets, IaC, …) for one scan. |
18
- | `codeant_scans_dismissed` | read | Dismissed alerts for a repo. |
18
+ | `codeant_scans_dismissed` | read | Dismissed alerts for a repo (any analysis type; `sast` and `anti_patterns` are accepted as aliases). |
19
+ | `codeant_scans_overrides` | read | User false-positive, confidence, and severity overrides for a repo. |
19
20
  | `codeant_hotlist_list` | read | Prioritized organization-wide Hotlist findings with stable IDs. |
20
21
  | `codeant_hotlist_get` | read | One complete Hotlist finding by stable ID. |
21
22
  | `codeant_findings_antipatterns` | read | Anti-pattern findings across selected or all organization repos (paged: `limit`, `offset`, `all`). |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeant-cli",
3
- "version": "0.5.8",
3
+ "version": "0.5.9",
4
4
  "description": "Code review CLI tool",
5
5
  "type": "module",
6
6
  "repository": {
@@ -36,6 +36,7 @@
36
36
  "./scans/fetch-results": "./src/scans/fetchScanResults.js",
37
37
  "./scans/fetch-advanced-results": "./src/scans/fetchAdvancedScanResults.js",
38
38
  "./scans/dismissed-alerts": "./src/scans/fetchDismissedAlerts.js",
39
+ "./scans/issue-overrides": "./src/scans/fetchIssueOverrides.js",
39
40
  "./hotlist": "./src/hotlist/client.js",
40
41
  "./findings/cloud": "./src/findings/cloud.js",
41
42
  "./findings/pentest": "./src/findings/pentest.js",
package/scans.md CHANGED
@@ -135,11 +135,12 @@ codeant scans results --repo <owner/repo> [options]
135
135
  | `--repo <repo>` | **(required)** Repository in `owner/repo` format |
136
136
  | `--scan <sha>` | Specific commit SHA to use |
137
137
  | `--branch <name>` | Resolve latest scan on this branch |
138
- | `--types <list>` | Comma-separated types: `sast`, `sca`, `secrets`, `iac`, `dead_code`, `sbom`, `anti_patterns`, `docstring`, `complex_functions`, `all` (default: `all`) |
138
+ | `--types <list>` | Comma-separated types: `sast`, `sca`, `secrets`, `iac`, `dead_code`, `duplicate_code`, `sbom`, `anti_patterns`, `docstring`, `complex_functions`, `all` (default: `all`) |
139
139
  | `--severity <list>` | Filter by severity (e.g. `critical,high`) |
140
140
  | `--path <glob>` | Filter by file path glob |
141
141
  | `--check <regex>` | Filter by check ID or name (regex) |
142
- | `--include-dismissed` | Include dismissed findings (excluded by default) |
142
+ | `--filter-dismissed` | Exclude dismissed findings (included by default, marked `metadata.dismissed`) |
143
+ | `--no-false-positives` | Exclude false positives, including ones users marked in the app (included by default, marked `metadata.false_positive`) |
143
144
  | `--format <fmt>` | Output format: `json`, `sarif`, `csv`, `md`, `table` (default: `json`) |
144
145
  | `--output <path>` | Write output to file instead of stdout |
145
146
  | `--fields <list>` | Project findings to a subset of fields (comma-separated) |
@@ -173,8 +174,8 @@ codeant scans results --repo acme/backend --format md
173
174
  # Output as SARIF to a file
174
175
  codeant scans results --repo acme/backend --format sarif --output results.sarif
175
176
 
176
- # Include dismissed findings
177
- codeant scans results --repo acme/backend --include-dismissed
177
+ # Only open findings: drop dismissed findings and false positives
178
+ codeant scans results --repo acme/backend --filter-dismissed --no-false-positives
178
179
 
179
180
  # Paginate through results
180
181
  codeant scans results --repo acme/backend --limit 50 --offset 100
@@ -206,7 +207,7 @@ codeant scans dismissed --repo <owner/repo> [options]
206
207
  | Option | Description |
207
208
  |--------|-------------|
208
209
  | `--repo <repo>` | **(required)** Repository in `owner/repo` format |
209
- | `--analysis-type <type>` | Analysis type: `security` or `secrets` (default: `security`) |
210
+ | `--analysis-type <type>` | `security`, `secrets`, `sca`, `iac`, `antipatterns`, `docstring`, `complex_functions`, `dead_code`, or `duplicate_code` (default: `security`). Result-type names `sast` and `anti_patterns` are accepted too. |
210
211
 
211
212
  **Examples:**
212
213
 
@@ -220,6 +221,16 @@ codeant scans dismissed --repo acme/backend --analysis-type secrets
220
221
 
221
222
  ---
222
223
 
224
+ ### `scans overrides`
225
+
226
+ List the per-finding overrides users set in the CodeAnt app: Mark/Unmark false positive (`security`, `iac`), secrets confidence (`secrets`), and Change Severity (`security`, `sca`). `scans results` already applies them.
227
+
228
+ ```bash
229
+ codeant scans overrides --repo <owner/repo> [--analysis-type security|secrets|iac|sca]
230
+ ```
231
+
232
+ ---
233
+
223
234
  ### `scans start-scan`
224
235
 
225
236
  Trigger a new analysis run for a repository.
@@ -1,16 +1,34 @@
1
1
  import { fetchDismissedAlerts } from '../../scans/fetchDismissedAlerts.js';
2
+ import { fetchIssueOverrides } from '../../scans/fetchIssueOverrides.js';
2
3
 
3
- /**
4
- * codeant scans dismissed --repo <repo> [--analysis-type security|secrets]
5
- */
6
- export async function runDismissed({ repo, analysisType = 'security' } = {}) {
4
+ // Result-type names (`scans results --types`) → the names dismissals and
5
+ // overrides are stored under.
6
+ const STORE_ANALYSIS_TYPES = {
7
+ sast: 'security',
8
+ security_issues: 'security',
9
+ anti_patterns: 'antipatterns',
10
+ };
11
+
12
+ export function storeAnalysisType(analysisType) {
13
+ return STORE_ANALYSIS_TYPES[analysisType] || analysisType;
14
+ }
15
+
16
+ function requireRepo(repo) {
7
17
  if (!repo) {
8
18
  const err = new Error('--repo is required');
9
19
  err.exitCode = 1;
10
20
  throw err;
11
21
  }
22
+ }
23
+
24
+ /**
25
+ * codeant scans dismissed --repo <repo> [--analysis-type <type>]
26
+ */
27
+ export async function runDismissed({ repo, analysisType = 'security' } = {}) {
28
+ requireRepo(repo);
29
+ const type = storeAnalysisType(analysisType);
12
30
 
13
- const result = await fetchDismissedAlerts(repo, analysisType);
31
+ const result = await fetchDismissedAlerts(repo, type);
14
32
  if (!result.success) {
15
33
  const err = new Error(result.error || 'Failed to fetch dismissed alerts');
16
34
  err.exitCode = 1;
@@ -19,8 +37,30 @@ export async function runDismissed({ repo, analysisType = 'security' } = {}) {
19
37
 
20
38
  return {
21
39
  repo,
22
- analysis_type: analysisType,
40
+ analysis_type: type,
23
41
  total: result.dismissedAlerts.length,
24
42
  dismissed_alerts: result.dismissedAlerts,
25
43
  };
26
44
  }
45
+
46
+ /**
47
+ * codeant scans overrides --repo <repo> [--analysis-type security|secrets|iac|sca]
48
+ */
49
+ export async function runOverrides({ repo, analysisType = 'security' } = {}) {
50
+ requireRepo(repo);
51
+ const type = storeAnalysisType(analysisType);
52
+
53
+ const result = await fetchIssueOverrides(repo, type);
54
+ if (!result.success) {
55
+ const err = new Error(result.error || 'Failed to fetch issue overrides');
56
+ err.exitCode = 1;
57
+ throw err;
58
+ }
59
+
60
+ return {
61
+ repo,
62
+ analysis_type: type,
63
+ total: Object.keys(result.overrides).length,
64
+ overrides: result.overrides,
65
+ };
66
+ }
@@ -3,10 +3,14 @@ import { runRepos } from './repos.js';
3
3
  import { runHistory } from './history.js';
4
4
  import { runGet } from './get.js';
5
5
  import { runResults } from './results.js';
6
- import { runDismissed } from './dismissed.js';
6
+ import { runDismissed, runOverrides } from './dismissed.js';
7
7
  import { runStartScan } from './start-scan.js';
8
8
  import { setQuiet, setNoColor } from './lib/log.js';
9
9
  import { setNoColor as tableSetNoColor } from './formatters/table.js';
10
+ import { intOption } from '../../utils/intOption.js';
11
+
12
+ const toCount = intOption({ min: 1 });
13
+ const toOffset = intOption();
10
14
 
11
15
  /**
12
16
  * Register all `codeant scans <verb>` subcommands.
@@ -37,7 +41,7 @@ export default function registerScansCommands(program, { runCmd }) {
37
41
  .requiredOption('--repo <repo>', 'Repository (owner/repo)')
38
42
  .option('--branch <name>', 'Filter by branch name')
39
43
  .option('--since <iso>', 'Show scans since ISO date')
40
- .option('--limit <n>', 'Max results (default: 20)', parseInt, 20)
44
+ .option('--limit <n>', 'Max results (default: 20)', toCount, 20)
41
45
  .action((opts) =>
42
46
  runCmd(() => runHistory({ repo: opts.repo, branch: opts.branch, since: opts.since, limit: opts.limit }))
43
47
  );
@@ -65,7 +69,7 @@ export default function registerScansCommands(program, { runCmd }) {
65
69
  .option('--branch <name>', 'Resolve latest scan on this branch')
66
70
  .option(
67
71
  '--types <list>',
68
- 'Comma-separated types: sast,sca,secrets,iac,dead_code,sbom,anti_patterns,docstring,complex_functions,all',
72
+ 'Comma-separated types: sast,sca,secrets,iac,dead_code,duplicate_code,sbom,anti_patterns,docstring,complex_functions,all',
69
73
  'all'
70
74
  )
71
75
  .option('--severity <list>', 'Filter by severity (e.g. critical,high)')
@@ -76,8 +80,8 @@ export default function registerScansCommands(program, { runCmd }) {
76
80
  .option('--format <fmt>', 'Output format: json|sarif|csv|md|table (default: json)', 'json')
77
81
  .option('--output <path>', 'Write output to file instead of stdout')
78
82
  .option('--fields <list>', 'Project findings to subset of fields (comma-separated)')
79
- .option('--limit <n>', 'Max findings per page (default: 100)', parseInt, 100)
80
- .option('--offset <n>', 'Pagination offset (default: 0)', parseInt, 0)
83
+ .option('--limit <n>', 'Max findings per page (default: 100)', toCount, 100)
84
+ .option('--offset <n>', 'Pagination offset (default: 0)', toOffset, 0)
81
85
  .option('--fail-fast', 'Exit 3 on first category fetch failure')
82
86
  .option('--no-color', 'Disable ANSI color (auto-disabled when not a TTY)')
83
87
  .option('--quiet', 'Suppress progress output on stderr')
@@ -117,11 +121,25 @@ export default function registerScansCommands(program, { runCmd }) {
117
121
  .command('dismissed')
118
122
  .description('List dismissed alerts for a repository')
119
123
  .requiredOption('--repo <repo>', 'Repository (owner/repo)')
120
- .option('--analysis-type <type>', 'Analysis type: security|secrets (default: security)', 'security')
124
+ .option(
125
+ '--analysis-type <type>',
126
+ 'Analysis type: security|secrets|sca|iac|antipatterns|docstring|complex_functions|dead_code|duplicate_code (default: security)',
127
+ 'security'
128
+ )
121
129
  .action((opts) =>
122
130
  runCmd(() => runDismissed({ repo: opts.repo, analysisType: opts.analysisType }))
123
131
  );
124
132
 
133
+ // ── overrides ──────────────────────────────────────────────────────────────
134
+ scans
135
+ .command('overrides')
136
+ .description('List user overrides (false positive, confidence, severity) for a repository')
137
+ .requiredOption('--repo <repo>', 'Repository (owner/repo)')
138
+ .option('--analysis-type <type>', 'Analysis type: security|secrets|iac|sca (default: security)', 'security')
139
+ .action((opts) =>
140
+ runCmd(() => runOverrides({ repo: opts.repo, analysisType: opts.analysisType }))
141
+ );
142
+
125
143
  // ── start-scan ─────────────────────────────────────────────────────────────
126
144
  scans
127
145
  .command('start-scan')
@@ -10,6 +10,7 @@ import {
10
10
  fetchSecretsResults,
11
11
  fetchIacResults,
12
12
  fetchDeadCodeResults,
13
+ fetchDuplicateCodeResults,
13
14
  } from '../../../scans/fetchAdvancedScanResults.js';
14
15
 
15
16
  /**
@@ -26,6 +27,7 @@ export const CATEGORIES = {
26
27
  secrets: { fetcher: fetchSecretsResults, kind: 'secret' },
27
28
  iac: { fetcher: fetchIacResults, kind: 'config' },
28
29
  dead_code: { fetcher: fetchDeadCodeResults, kind: 'code' },
30
+ duplicate_code: { fetcher: fetchDuplicateCodeResults, kind: 'code' },
29
31
  };
30
32
 
31
33
  /**
@@ -87,6 +87,20 @@ export function normalizeIssue(issue, category) {
87
87
  } else if (category === 'sca') {
88
88
  if (issue.cvss_score !== undefined) metadata.cvss_score = issue.cvss_score;
89
89
  if (issue.fix_version) metadata.fix_version = issue.fix_version;
90
+ } else if (category === 'duplicate_code') {
91
+ if (issue.duplicate_group !== undefined) metadata.duplicate_group = issue.duplicate_group;
92
+ if (issue.duplicate_locations) metadata.duplicate_locations = issue.duplicate_locations;
93
+ }
94
+
95
+ // Backend dispositions, present when dismissed / false positives are not filtered out.
96
+ if (issue.is_dismissed === true) {
97
+ metadata.dismissed = true;
98
+ if (issue.reason_for_dismiss) metadata.reason_for_dismiss = issue.reason_for_dismiss;
99
+ if (issue.comment_for_dismiss) metadata.comment_for_dismiss = issue.comment_for_dismiss;
100
+ }
101
+ // Secrets from older backends only carry the FALSE_POSITIVE confidence.
102
+ if (issue.is_false_positive === true || String(issue.confidence_score || '').toUpperCase() === 'FALSE_POSITIVE') {
103
+ metadata.false_positive = true;
90
104
  }
91
105
 
92
106
  const id = buildId(category, filePath, lineNumber, checkId, message);
package/src/index.js CHANGED
@@ -20,6 +20,7 @@ import ScanCenter from './components/ScanCenter.js';
20
20
  import Welcome from './components/Welcome.js';
21
21
  import * as scm from './scm/index.js';
22
22
  import { setConfigValue } from './utils/config.js';
23
+ import { intOption } from './utils/intOption.js';
23
24
  import { track, shutdown as analyticsShutdown, isTelemetryDisabled } from './utils/analytics.js';
24
25
  import registerScansCommands from './commands/scans/index.js';
25
26
  import registerSettingsCommands from './commands/settings/index.js';
@@ -31,6 +32,9 @@ import registerFindingsCommands from './commands/findings/index.js';
31
32
  const require = createRequire(import.meta.url);
32
33
  const pkg = require('../package.json');
33
34
 
35
+ const toCount = intOption({ min: 1 });
36
+ const toOffset = intOption();
37
+
34
38
  // Split comma-separated globs while preserving commas inside {} brace expansions (e.g. "*.{js,ts}")
35
39
  function splitGlobs(input) {
36
40
  const parts = [];
@@ -67,7 +71,7 @@ program
67
71
  .option('--uncommitted', 'Scan only uncommitted changes (staged + unstaged + untracked)')
68
72
  .option('--staged', 'Scan only staged files')
69
73
  .option('--last-commit', 'Scan last commit')
70
- .option('--last-n-commits <n>', 'Scan last n commits (max 5)', parseInt)
74
+ .option('--last-n-commits <n>', 'Scan last n commits (max 5)', toCount)
71
75
  .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
72
76
  .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
73
77
  .option('--include <paths>', 'Comma-separated list of file paths glob patterns to include')
@@ -118,7 +122,7 @@ program
118
122
  .option('--uncommitted', 'Review only uncommitted changes (staged + unstaged + untracked)')
119
123
  .option('--staged', 'Review only staged files')
120
124
  .option('--last-commit', 'Review last commit')
121
- .option('--last-n-commits <n>', 'Review last n commits (max 5)', parseInt)
125
+ .option('--last-n-commits <n>', 'Review last n commits (max 5)', toCount)
122
126
  .option('--base <branch>', 'Compare against a specific base branch (e.g. --base develop)')
123
127
  .option('--base-commit <commit>', 'Compare against a specific commit (e.g. --base-commit HEAD~3)')
124
128
  .option('--fail-on <level>', 'Fail on issues at or above this level: BLOCKER, CRITICAL, MAJOR, MINOR, INFO (default: CRITICAL)', 'CRITICAL')
@@ -299,8 +303,8 @@ program
299
303
  .option('--source-branch <branch>', 'Filter by source branch (partial match)')
300
304
  .option('--author <login>', 'Filter by author (fuzzy match)')
301
305
  .option('--state <state>', 'open or closed (default: open)', 'open')
302
- .option('--limit <n>', 'Max results (default: 20, max: 100)', parseInt, 20)
303
- .option('--offset <n>', 'Pagination offset', parseInt, 0)
306
+ .option('--limit <n>', 'Max results (default: 20, max: 100)', intOption({ min: 1, max: 100 }), 20)
307
+ .option('--offset <n>', 'Pagination offset', toOffset, 0)
304
308
  .action((options) => {
305
309
  const opts = resolveRepoOpts(options);
306
310
  runCmd(() => scm.listPullRequests({
@@ -315,7 +319,7 @@ program
315
319
  .option('--name <repo>', 'Repository (owner/repo)')
316
320
  .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
317
321
  .option('--default-branch <branch>', 'Default branch name')
318
- .requiredOption('--pr-number <n>', 'PR number', parseInt)
322
+ .requiredOption('--pr-number <n>', 'PR number', toCount)
319
323
  .action((options) => {
320
324
  const opts = resolveRepoOpts(options);
321
325
  runCmd(() => scm.getPullRequest({
@@ -329,7 +333,7 @@ program
329
333
  .option('--name <repo>', 'Repository (owner/repo)')
330
334
  .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
331
335
  .option('--default-branch <branch>', 'Default branch name')
332
- .requiredOption('--pr-number <n>', 'PR number', parseInt)
336
+ .requiredOption('--pr-number <n>', 'PR number', toCount)
333
337
  .option('--codeant-generated <bool>', 'Filter by CodeAnt authorship (true/false)', (v) => v === 'true')
334
338
  .option('--addressed', 'Filter by addressed/resolved status')
335
339
  .option('--created-after <date>', 'ISO 8601 date filter')
@@ -347,8 +351,8 @@ program
347
351
  .description('Resolve a conversation/comment thread on a PR')
348
352
  .option('--name <repo>', 'Repository (owner/repo)')
349
353
  .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
350
- .requiredOption('--pr-number <n>', 'PR number', parseInt)
351
- .option('--comment-id <id>', 'Comment ID (GitHub, Bitbucket)', parseInt)
354
+ .requiredOption('--pr-number <n>', 'PR number', toCount)
355
+ .option('--comment-id <id>', 'Comment ID (GitHub, Bitbucket)', toCount)
352
356
  .option('--thread-id <id>', 'Thread/node ID (GitHub GraphQL, Azure)')
353
357
  .option('--discussion-id <id>', 'Discussion ID (GitLab)')
354
358
  .action((options) => {
@@ -370,10 +374,10 @@ program
370
374
  .option('--name <repo>', 'Repository (owner/repo)')
371
375
  .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
372
376
  .option('--default-branch <branch>', 'Default branch name')
373
- .option('--pr-number <n>', 'Filter by PR number', parseInt)
377
+ .option('--pr-number <n>', 'Filter by PR number', toCount)
374
378
  .option('--status <status>', 'Filter by status (PENDING, COMPLETED, FAILED, etc.)')
375
- .option('--limit <n>', 'Max results (default: 20)', parseInt, 20)
376
- .option('--offset <n>', 'Pagination offset', parseInt, 0)
379
+ .option('--limit <n>', 'Max results (default: 20)', toCount, 20)
380
+ .option('--offset <n>', 'Pagination offset', toOffset, 0)
377
381
  .action((options) => {
378
382
  const opts = resolveRepoOpts(options);
379
383
  runCmd(() => scm.listCodeReviews({
@@ -387,7 +391,7 @@ program
387
391
  .description('Get detailed information for a specific code review')
388
392
  .option('--name <repo>', 'Repository (owner/repo)')
389
393
  .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
390
- .requiredOption('--pr-number <n>', 'PR number', parseInt)
394
+ .requiredOption('--pr-number <n>', 'PR number', toCount)
391
395
  .requiredOption('--review-id <id>', 'Code review ID')
392
396
  .action((options) => {
393
397
  const opts = resolveRepoOpts(options);
@@ -405,7 +409,7 @@ program
405
409
  .option('--name <repo>', 'Repository (owner/repo)')
406
410
  .option('--remote <provider>', 'github, gitlab, bitbucket, azure')
407
411
  .requiredOption('--query <term>', 'Search term')
408
- .option('--limit <n>', 'Max results (default: 10, max: 50)', parseInt, 10)
412
+ .option('--limit <n>', 'Max results (default: 10, max: 50)', intOption({ min: 1, max: 50 }), 10)
409
413
  .option('--include-addressed', 'Include resolved comments (default: false)')
410
414
  .option('--created-after <date>', 'ISO 8601 date filter')
411
415
  .action((options) => {
package/src/mcp/server.js CHANGED
@@ -8,7 +8,7 @@ import { runRepos } from '../commands/scans/repos.js';
8
8
  import { runHistory } from '../commands/scans/history.js';
9
9
  import { runGet } from '../commands/scans/get.js';
10
10
  import { runResults } from '../commands/scans/results.js';
11
- import { runDismissed } from '../commands/scans/dismissed.js';
11
+ import { runDismissed, runOverrides } from '../commands/scans/dismissed.js';
12
12
  import { runStartScan } from '../commands/scans/start-scan.js';
13
13
  import { runReviewHeadless } from '../reviewHeadless.js';
14
14
  import * as scm from '../scm/index.js';
@@ -164,12 +164,12 @@ export async function startMcpServer() {
164
164
  repo: z.string().describe('Repository in owner/repo form.'),
165
165
  scan: z.string().optional().describe('Specific commit SHA.'),
166
166
  branch: z.string().optional().describe('Resolve the latest scan on this branch.'),
167
- types: z.string().optional().describe('Comma-separated types: sast,sca,secrets,iac,dead_code,sbom,anti_patterns,docstring,complex_functions,all (default "all").'),
167
+ types: z.string().optional().describe('Comma-separated types: sast,sca,secrets,iac,dead_code,duplicate_code,sbom,anti_patterns,docstring,complex_functions,all (default "all").'),
168
168
  severity: z.string().optional().describe('Comma-separated severities (e.g. "critical,high").'),
169
169
  path: z.string().optional().describe('File path glob filter.'),
170
170
  check: z.string().optional().describe('Filter by check ID or name (regex).'),
171
- filterDismissed: z.boolean().optional().describe('Exclude dismissed findings (default false).'),
172
- includeFalsePositives: z.boolean().optional().describe('Include false positives (default true).'),
171
+ filterDismissed: z.boolean().optional().describe('Exclude dismissed findings (default false). When false, dismissed findings carry metadata.dismissed.'),
172
+ includeFalsePositives: z.boolean().optional().describe('Include false positives, including user-marked ones (default true). When true, they carry metadata.false_positive.'),
173
173
  fields: z.string().optional().describe('Project findings to a subset of fields (comma-separated).'),
174
174
  limit: z.number().int().positive().max(500).optional().describe('Max findings per page (default 100).'),
175
175
  offset: z.number().int().nonnegative().optional().describe('Pagination offset (default 0).'),
@@ -212,7 +212,7 @@ export async function startMcpServer() {
212
212
  description: 'List dismissed alerts (false positives, accepted risk, etc.) for a repository. Useful when triaging to avoid re-surfacing already-handled findings.',
213
213
  inputSchema: {
214
214
  repo: z.string().describe('Repository in owner/repo form.'),
215
- analysisType: z.enum(['security', 'secrets']).optional().describe('Analysis type (default "security").'),
215
+ analysisType: z.enum(['security', 'sast', 'secrets', 'sca', 'iac', 'antipatterns', 'anti_patterns', 'docstring', 'complex_functions', 'dead_code', 'duplicate_code']).optional().describe('Analysis type (default "security"; sast and anti_patterns are aliases).'),
216
216
  },
217
217
  annotations: READ,
218
218
  },
@@ -221,6 +221,22 @@ export async function startMcpServer() {
221
221
  }
222
222
  );
223
223
 
224
+ server.registerTool(
225
+ 'codeant_scans_overrides',
226
+ {
227
+ title: 'List user issue overrides',
228
+ description: 'List per-finding overrides users set in the CodeAnt app for a repository: Mark/Unmark false positive (security, iac), secrets confidence, and Change Severity (security, sca). Results from codeant_scans_results already apply them; use this to explain why a finding is hidden or re-rated.',
229
+ inputSchema: {
230
+ repo: z.string().describe('Repository in owner/repo form.'),
231
+ analysisType: z.enum(['security', 'secrets', 'iac', 'sca']).optional().describe('Analysis type (default "security").'),
232
+ },
233
+ annotations: READ,
234
+ },
235
+ async ({ repo, analysisType }) => {
236
+ try { return ok(await runOverrides({ repo, analysisType: analysisType ?? 'security' })); } catch (err) { return fail(err); }
237
+ }
238
+ );
239
+
224
240
  // ─── Organization Hotlist findings (read-only) ──────────────────────────
225
241
  server.registerTool(
226
242
  'codeant_hotlist_list',
@@ -6,8 +6,23 @@ export const ADVANCED_RESULT_TYPES = {
6
6
  SECRETS: 'secrets',
7
7
  IAC: 'iac',
8
8
  DEAD_CODE: 'dead_code',
9
+ DUPLICATE_CODE: 'duplicate_code',
9
10
  };
10
11
 
12
+ const BACKEND_MARKERS = ['is_dismissed', 'reason_for_dismiss', 'comment_for_dismiss', 'is_false_positive'];
13
+
14
+ /**
15
+ * Dismissed / false-positive markers the backend stamps on each finding. Kept
16
+ * when a finding is rebuilt so callers can hide or show those findings.
17
+ */
18
+ function backendMarkers(item) {
19
+ const markers = {};
20
+ for (const key of BACKEND_MARKERS) {
21
+ if (item?.[key] !== undefined) markers[key] = item[key];
22
+ }
23
+ return markers;
24
+ }
25
+
11
26
  const EXTRA_DEAD_CODE_MESSAGES = {
12
27
  S1481: 'Unused local variable',
13
28
  S1854: 'Unused assignment',
@@ -74,12 +89,16 @@ function flattenDeadCode(deadCodeData) {
74
89
  name,
75
90
  confidence: confMatch ? confMatch[1] : '90',
76
91
  check_name: `Unused ${issueType}: ${name}`,
92
+ ...backendMarkers(issue),
77
93
  });
78
94
  });
79
95
  });
80
96
 
81
- // JS dead code — unused files
97
+ // JS dead code — unused files (plain path strings; the backend lists dismissed ones separately)
82
98
  const jsDeadCode = deadCodeData.js_dead_code || {};
99
+ const dismissedFiles = Array.isArray(jsDeadCode.dismissed_unused_files)
100
+ ? new Set(jsDeadCode.dismissed_unused_files)
101
+ : null;
83
102
  (jsDeadCode.unused_files || []).forEach((filePath) => {
84
103
  if (!filePath) return;
85
104
  flat.push({
@@ -90,6 +109,7 @@ function flattenDeadCode(deadCodeData) {
90
109
  severity: 'warning',
91
110
  type: 'unused_file',
92
111
  check_name: 'Unused file',
112
+ ...(dismissedFiles ? { is_dismissed: dismissedFiles.has(filePath) } : {}),
93
113
  });
94
114
  });
95
115
 
@@ -110,6 +130,7 @@ function flattenDeadCode(deadCodeData) {
110
130
  type: 'unused_export',
111
131
  name: exp.name,
112
132
  check_name: `Unused export: ${exp.name || 'unknown'}`,
133
+ ...backendMarkers(exp),
113
134
  });
114
135
  });
115
136
  });
@@ -124,7 +145,8 @@ function flattenDeadCode(deadCodeData) {
124
145
  const messageId = issue['message-id'] || '';
125
146
  const msgKey = messageId.includes(':') ? messageId.split(':')[1] : messageId;
126
147
  flat.push({
127
- file_path: extractRelativeFilePath(filePath),
148
+ // Keys end with the per-file result name (".../app.py/dead_code.json").
149
+ file_path: extractRelativeFilePath(filePath).replace(/\/[^/]+\.json$/, ''),
128
150
  line_number: issue.line_number || 0,
129
151
  issue_text: issue.issue_text || EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
130
152
  message: issue.issue_text || EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
@@ -133,6 +155,7 @@ function flattenDeadCode(deadCodeData) {
133
155
  rule_id: msgKey,
134
156
  confidence: issue.confidence || '90',
135
157
  check_name: EXTRA_DEAD_CODE_MESSAGES[msgKey] || 'Dead code detected',
158
+ ...backendMarkers(issue),
136
159
  });
137
160
  });
138
161
  });
@@ -141,6 +164,49 @@ function flattenDeadCode(deadCodeData) {
141
164
  return flat;
142
165
  }
143
166
 
167
+ /** "[12-40]" → [12, 40]; anything unparsable → []. */
168
+ function parseWindow(window) {
169
+ const numbers = String(window ?? '').match(/\d+/g) || [];
170
+ return numbers.slice(0, 2).map(Number);
171
+ }
172
+
173
+ /**
174
+ * Flatten duplicate-code clone groups (each a list of { fileName, window }
175
+ * members) into one finding per member, linked by their group index.
176
+ */
177
+ function flattenDuplicateCode(groups) {
178
+ if (!Array.isArray(groups)) return [];
179
+ const flat = [];
180
+ groups.forEach((group, groupIndex) => {
181
+ const members = Array.isArray(group) ? group.filter((m) => m && typeof m === 'object') : [];
182
+ const locations = members.map((m) => {
183
+ const [start, end] = parseWindow(m.window);
184
+ return { file_path: extractRelativeFilePath(cleanLeadingSlash(m.fileName)), start, end };
185
+ });
186
+ members.forEach((member, i) => {
187
+ const { file_path, start, end } = locations[i];
188
+ const others = locations
189
+ .filter((_, j) => j !== i)
190
+ .map((o) => (o.start ? `${o.file_path}:${o.start}${o.end ? `-${o.end}` : ''}` : o.file_path));
191
+ const message = `Duplicated block (${members.length} locations)`;
192
+ flat.push({
193
+ file_path,
194
+ line_number: start || 1,
195
+ file_line_range: end ? [start, end] : [start || 1],
196
+ check_id: 'duplicate-code',
197
+ check_name: message,
198
+ issue_text: message,
199
+ message,
200
+ severity: 'low',
201
+ duplicate_group: member.originalIndex ?? groupIndex,
202
+ duplicate_locations: others,
203
+ ...backendMarkers(member),
204
+ });
205
+ });
206
+ });
207
+ return flat;
208
+ }
209
+
144
210
  /**
145
211
  * Normalize a single advanced issue to a consistent shape.
146
212
  */
@@ -163,7 +229,8 @@ function normalizeAdvancedIssue(item, resultType) {
163
229
  item.vulnerability_id || item.cve_id || item.advisory_id ||
164
230
  (item.package_name ? `Vulnerability in ${item.package_name}` : null) ||
165
231
  item.title || item.description || 'Package vulnerability detected';
166
- normalized.severity = item.severity || 'medium';
232
+ // SCA findings carry severity in ratings (where Change Severity overrides land).
233
+ normalized.severity = item.severity || item.ratings?.[0]?.severity || 'medium';
167
234
  break;
168
235
  case ADVANCED_RESULT_TYPES.SBOM:
169
236
  normalized.check_name = item.package_name
@@ -187,6 +254,10 @@ function normalizeAdvancedIssue(item, resultType) {
187
254
  item.check_name || item.name || item.function_name || item.symbol_name || item.description || 'Unused code detected';
188
255
  normalized.severity = item.severity || 'low';
189
256
  break;
257
+ case ADVANCED_RESULT_TYPES.DUPLICATE_CODE:
258
+ normalized.check_name = item.check_name || 'Duplicated block';
259
+ normalized.severity = item.severity || 'low';
260
+ break;
190
261
  default:
191
262
  normalized.check_name = item.description || item.message || item.name || 'Issue detected';
192
263
  }
@@ -195,7 +266,7 @@ function normalizeAdvancedIssue(item, resultType) {
195
266
  }
196
267
 
197
268
  /**
198
- * Fetch advanced scan results (SCA, SBOM, secrets, IaC, dead code).
269
+ * Fetch advanced scan results (SCA, SBOM, secrets, IaC, dead code, duplicate code).
199
270
  *
200
271
  * @param {string} repo - "org/repo-name"
201
272
  * @param {string} commitId - 40-char commit SHA
@@ -237,6 +308,8 @@ export async function fetchAdvancedScanResults(repo, commitId, resultType, opts
237
308
  resultsData = response.secrets;
238
309
  } else if (resultType === ADVANCED_RESULT_TYPES.DEAD_CODE) {
239
310
  resultsData = flattenDeadCode(response.dead_code);
311
+ } else if (resultType === ADVANCED_RESULT_TYPES.DUPLICATE_CODE) {
312
+ resultsData = flattenDuplicateCode(response.results);
240
313
  } else if (resultType === ADVANCED_RESULT_TYPES.SCA) {
241
314
  const scaResults = response.results;
242
315
  if (scaResults && typeof scaResults === 'object' && !Array.isArray(scaResults) && scaResults.all_vulnerabilities !== undefined) {
@@ -268,6 +341,7 @@ export async function fetchAdvancedScanResults(repo, commitId, resultType, opts
268
341
  guideline: check.guideline || '',
269
342
  code_block: check.code_block || [],
270
343
  resource: check.resource || '',
344
+ ...backendMarkers(check),
271
345
  });
272
346
  });
273
347
  });
@@ -328,3 +402,6 @@ export const fetchIacResults = (repo, commitId, opts) =>
328
402
 
329
403
  export const fetchDeadCodeResults = (repo, commitId, opts) =>
330
404
  fetchAdvancedScanResults(repo, commitId, ADVANCED_RESULT_TYPES.DEAD_CODE, opts);
405
+
406
+ export const fetchDuplicateCodeResults = (repo, commitId, opts) =>
407
+ fetchAdvancedScanResults(repo, commitId, ADVANCED_RESULT_TYPES.DUPLICATE_CODE, opts);
@@ -29,7 +29,21 @@ export async function fetchDismissedAlerts(repo, analysisType = 'security') {
29
29
  const dismissedAlerts = [];
30
30
 
31
31
  for (const [issueKey, dismissInfo] of Object.entries(dismissData)) {
32
- if (!issueKey.includes('||::||')) continue;
32
+ if (!issueKey.includes('||::||')) {
33
+ // SCA ("pkg@version[::manifest::cve]") and duplicate-code (group index)
34
+ // keys have no file/line parts; keep them so the count is complete.
35
+ dismissedAlerts.push({
36
+ file_path: '',
37
+ context_code_block: '',
38
+ test_id: '',
39
+ line_number: 0,
40
+ type: '',
41
+ issue_key: issueKey,
42
+ reason_for_dismiss: dismissInfo?.reason_for_dismiss || '',
43
+ comment_for_dismiss: dismissInfo?.comment_for_dismiss || '',
44
+ });
45
+ continue;
46
+ }
33
47
 
34
48
  const parts = issueKey.split('||::||');
35
49
  let file_path = parts[0] || '';
@@ -0,0 +1,36 @@
1
+ import { fetchApi } from '../utils/fetchApi.js';
2
+
3
+ /**
4
+ * Fetch the per-finding user overrides the web app saves for a repository
5
+ * (Mark/Unmark false positive, secrets confidence, Change Severity).
6
+ *
7
+ * Override keys and patches by analysis type:
8
+ * security — "id::<tracking id>|rule::<test_id>||::||<result key>||::||<line>" → { false_positive, severity }
9
+ * secrets — "<path>||::||<hashed_secret or line>||::||<type>" → { confidence_score }
10
+ * iac — "<file_path>||::||<check_id>||::||<start>-<end>" → { false_positive }
11
+ * sca — "<package>@<version>" → { severity }
12
+ *
13
+ * @param {string} repo - "org/repo-name"
14
+ * @param {string} analysisType - security | secrets | iac | sca
15
+ * @returns {Promise<{ success: boolean, overrides?: Object, error?: string }>}
16
+ */
17
+ export async function fetchIssueOverrides(repo, analysisType) {
18
+ try {
19
+ const response = await fetchApi('/extension/scans2/change-issue-details/get', 'POST', {
20
+ repo,
21
+ analysis_type: analysisType,
22
+ });
23
+
24
+ if (!response) {
25
+ return { success: false, error: 'Failed to connect to CodeAnt server' };
26
+ }
27
+
28
+ if (response.status === 'error') {
29
+ return { success: false, error: response.message || 'Failed to fetch issue overrides' };
30
+ }
31
+
32
+ return { success: true, overrides: response.data || {} };
33
+ } catch (error) {
34
+ return { success: false, error: error.message || 'Failed to fetch issue overrides' };
35
+ }
36
+ }
@@ -70,7 +70,6 @@ export async function fetchScanResults(repo, commitId, resultType, opts = {}) {
70
70
  filter_dismissed: filterDismissed,
71
71
  include_false_positives: includeFalsePositives,
72
72
  });
73
- console.error("dropship: ", JSON.stringify(response, null, 2));
74
73
 
75
74
  if (!response) {
76
75
  return { success: false, error: 'Failed to connect to CodeAnt server' };
@@ -0,0 +1,18 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+
3
+ /**
4
+ * Commander parser for an integer option within [min, max]. Commander passes the
5
+ * option's default as the parser's second argument, so a bare `parseInt` would
6
+ * use it as the radix (`--limit 30` with default 20 → 60).
7
+ */
8
+ export function intOption({ min = 0, max = Infinity } = {}) {
9
+ return (value) => {
10
+ const n = Number(value);
11
+ if (!Number.isInteger(n) || n < min || n > max) {
12
+ throw new InvalidArgumentError(
13
+ max === Infinity ? `Must be an integer of at least ${min}.` : `Must be an integer from ${min} to ${max}.`
14
+ );
15
+ }
16
+ return n;
17
+ };
18
+ }