codeant-cli 0.4.5 → 0.4.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/package.json +8 -2
  2. package/src/commands/scans/dismissed.js +26 -0
  3. package/src/commands/scans/formatters/csv.js +24 -0
  4. package/src/commands/scans/formatters/index.js +12 -0
  5. package/src/commands/scans/formatters/json.js +8 -0
  6. package/src/commands/scans/formatters/md.js +63 -0
  7. package/src/commands/scans/formatters/sarif.js +105 -0
  8. package/src/commands/scans/formatters/table.js +83 -0
  9. package/src/commands/scans/get.js +64 -0
  10. package/src/commands/scans/history.js +43 -0
  11. package/src/commands/scans/index.js +121 -0
  12. package/src/commands/scans/lib/categories.js +51 -0
  13. package/src/commands/scans/lib/dismissMatch.js +32 -0
  14. package/src/commands/scans/lib/emit.js +25 -0
  15. package/src/commands/scans/lib/filters.js +72 -0
  16. package/src/commands/scans/lib/log.js +25 -0
  17. package/src/commands/scans/lib/normalize.js +155 -0
  18. package/src/commands/scans/lib/paginate.js +22 -0
  19. package/src/commands/scans/lib/resolveScan.js +50 -0
  20. package/src/commands/scans/lib/sort.js +19 -0
  21. package/src/commands/scans/orgs.js +15 -0
  22. package/src/commands/scans/repos.js +41 -0
  23. package/src/commands/scans/results.js +156 -0
  24. package/src/components/ScanCenter.js +337 -0
  25. package/src/components/SelectList.js +80 -0
  26. package/src/index.js +12 -0
  27. package/src/rules/secrets.toml +5 -0
  28. package/src/scanCenter/handleSelectConnection.js +14 -0
  29. package/src/scanCenter/handleSelectRepo.js +19 -0
  30. package/src/scanCenter/handleSelectResultType.js +34 -0
  31. package/src/scanCenter/handleSelectScan.js +4 -0
  32. package/src/scanCenter/validateConnectionOnMount.js +15 -0
  33. package/src/scans/connectionHandler.js +52 -0
  34. package/src/scans/fetchAdvancedScanResults.js +325 -0
  35. package/src/scans/fetchDismissedAlerts.js +57 -0
  36. package/src/scans/fetchScanResults.js +115 -0
  37. package/src/scans/getScanHistory.js +38 -0
  38. package/src/scans/listRepos.js +38 -0
@@ -0,0 +1,25 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ /**
5
+ * Emit rendered content.
6
+ * - If outputPath is set: write bytes to file, print JSON envelope to stdout.
7
+ * - Otherwise: write to stdout.
8
+ *
9
+ * @param {string} content - rendered string
10
+ * @param {string|null} outputPath
11
+ * @param {number} findingsCount - for the file envelope
12
+ */
13
+ export function emit(content, outputPath, findingsCount = 0) {
14
+ if (outputPath) {
15
+ const resolved = path.resolve(outputPath);
16
+ fs.writeFileSync(resolved, content, 'utf8');
17
+ const bytes = Buffer.byteLength(content, 'utf8');
18
+ process.stdout.write(
19
+ JSON.stringify({ output: resolved, bytes, findings: findingsCount }, null, 2) + '\n'
20
+ );
21
+ } else {
22
+ process.stdout.write(content);
23
+ if (!content.endsWith('\n')) process.stdout.write('\n');
24
+ }
25
+ }
@@ -0,0 +1,72 @@
1
+ import { minimatch } from 'minimatch';
2
+ import { isDismissed, findDismissMatch } from './dismissMatch.js';
3
+ import { normalizeSeverity } from './normalize.js';
4
+
5
+ const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0 };
6
+
7
+ /**
8
+ * Apply all filters to findings in-place (returns new array).
9
+ *
10
+ * @param {Array} findings - NormalizedFinding[]
11
+ * @param {object} opts
12
+ * @param {string[]|null} opts.severity - allowed severity levels (e.g. ['critical','high'])
13
+ * @param {string|null} opts.pathGlob - minimatch glob for file_path
14
+ * @param {string|null} opts.checkRegex - regex applied to check_id + check_name
15
+ * @param {Array} opts.dismissedAlerts - from fetchDismissedAlerts()
16
+ * @param {boolean} opts.includeDismissed
17
+ * @returns {Array} filtered NormalizedFinding[] (dismissed field annotated)
18
+ */
19
+ export function applyFilters(findings, {
20
+ severity = null,
21
+ pathGlob = null,
22
+ checkRegex = null,
23
+ dismissedAlerts = [],
24
+ includeDismissed = false,
25
+ } = {}) {
26
+ // Pre-compile regex — strip Python/PCRE inline flag (?i) and fold into JS flag
27
+ let checkRe = null;
28
+ if (checkRegex) {
29
+ try {
30
+ let pattern = checkRegex;
31
+ if (pattern.startsWith('(?i)')) pattern = pattern.slice(4);
32
+ checkRe = new RegExp(pattern, 'i');
33
+ } catch {
34
+ const err = new Error(`Invalid --check regex: ${checkRegex}`);
35
+ err.exitCode = 1;
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ // Severity set
41
+ const sevSet = severity && severity.length > 0
42
+ ? new Set(severity.map((s) => normalizeSeverity(s)))
43
+ : null;
44
+
45
+ const result = [];
46
+ for (const f of findings) {
47
+ // Annotate dismiss status
48
+ const match = findDismissMatch(f, dismissedAlerts);
49
+ f.dismissed = match !== null;
50
+ if (match) {
51
+ f.dismiss_info = {
52
+ reason: match.reason_for_dismiss || null,
53
+ comment: match.comment_for_dismiss || null,
54
+ };
55
+ }
56
+
57
+ // Filter dismissed
58
+ if (f.dismissed && !includeDismissed) continue;
59
+
60
+ // Filter by severity
61
+ if (sevSet && !sevSet.has(f.severity)) continue;
62
+
63
+ // Filter by path glob
64
+ if (pathGlob && !minimatch(f.file_path, pathGlob, { matchBase: true })) continue;
65
+
66
+ // Filter by check regex
67
+ if (checkRe && !checkRe.test(f.check_id) && !checkRe.test(f.check_name)) continue;
68
+
69
+ result.push(f);
70
+ }
71
+ return result;
72
+ }
@@ -0,0 +1,25 @@
1
+ // stderr progress logger — respects --quiet and --no-color
2
+
3
+ let quietMode = false;
4
+ let noColorMode = false;
5
+
6
+ export function setQuiet(q) { quietMode = !!q; }
7
+ export function setNoColor(nc) { noColorMode = !!nc; }
8
+
9
+ function isTTY() { return process.stderr.isTTY === true; }
10
+
11
+ function dim(text) {
12
+ if (noColorMode || !isTTY()) return text;
13
+ return `\x1b[2m${text}\x1b[0m`;
14
+ }
15
+
16
+ /** Write a progress line to stderr (suppressed by --quiet). */
17
+ export function progress(msg) {
18
+ if (quietMode) return;
19
+ process.stderr.write(dim(`[progress] ${msg}`) + '\n');
20
+ }
21
+
22
+ /** Write a JSON error object to stderr (never suppressed). */
23
+ export function logError(obj) {
24
+ process.stderr.write(JSON.stringify(obj) + '\n');
25
+ }
@@ -0,0 +1,155 @@
1
+ import { createRequire } from 'module';
2
+
3
+ const require = createRequire(import.meta.url);
4
+ const pkg = require('../../../../package.json');
5
+
6
+ const SEV_MAP = {
7
+ critical: 'critical', blocker: 'critical', p0: 'critical',
8
+ high: 'high', error: 'high', major: 'high',
9
+ medium: 'medium', warning: 'medium', moderate: 'medium', p2: 'medium',
10
+ low: 'low', note: 'low', minor: 'low', p3: 'low',
11
+ info: 'info', informational: 'info', information: 'info', p4: 'info',
12
+ };
13
+
14
+ export function normalizeSeverity(raw) {
15
+ if (!raw) return 'unknown';
16
+ return SEV_MAP[String(raw).toLowerCase()] ?? 'unknown';
17
+ }
18
+
19
+ /** Stable djb2-variant hash → 4-char hex suffix for finding IDs. */
20
+ function shortHash(str) {
21
+ let h = 5381;
22
+ for (let i = 0; i < str.length; i++) {
23
+ h = (((h << 5) + h) ^ str.charCodeAt(i)) >>> 0;
24
+ }
25
+ return h.toString(16).slice(0, 4);
26
+ }
27
+
28
+ /**
29
+ * Build a stable finding ID: category:file_path:line:check_id:hash
30
+ */
31
+ function buildId(category, filePath, lineNumber, checkId, message) {
32
+ const hash = shortHash(`${filePath}:${lineNumber}:${checkId}:${message}`);
33
+ return `${category}:${filePath}:${lineNumber}:${checkId || 'nocheck'}:${hash}`;
34
+ }
35
+
36
+ /**
37
+ * Normalize a raw issue (from any fetcher) into a NormalizedFinding.
38
+ *
39
+ * @param {object} issue - raw issue from fetcher
40
+ * @param {string} category - category key (e.g. 'sast')
41
+ * @returns {object} NormalizedFinding
42
+ */
43
+ export function normalizeIssue(issue, category) {
44
+ if (!issue) return null;
45
+
46
+ const filePath = issue.file_path || 'unknown';
47
+ const lineNumber = issue.line_number || issue.start_line || issue.line || 1;
48
+ const lineRange = issue.file_line_range || issue.line_range || [lineNumber];
49
+ const checkId = issue.check_id || issue.test_id || issue.rule_id || issue.vulnerability_id || issue.cve_id || '';
50
+ const checkName = issue.check_name || issue.issue_text || issue.message || issue.description || issue.name || '';
51
+ const message = issue.message || issue.issue_text || issue.description || checkName || '';
52
+ const severity = normalizeSeverity(issue.severity);
53
+
54
+ // Package info for sca/sbom
55
+ let packageInfo = null;
56
+ if (category === 'sca' || category === 'sbom') {
57
+ const name = issue.package_name || issue.name || null;
58
+ if (name) {
59
+ packageInfo = {
60
+ name,
61
+ version: issue.version || issue.package_version || null,
62
+ ecosystem: issue.ecosystem || issue.package_manager || null,
63
+ };
64
+ }
65
+ }
66
+
67
+ // CWE / CVE
68
+ const cwe = issue.cwe || issue.cwe_id || null;
69
+ const cve = issue.cve || issue.cve_id || issue.vulnerability_id || null;
70
+
71
+ // Category-specific metadata
72
+ const metadata = {};
73
+ if (category === 'iac') {
74
+ if (issue.guideline) metadata.guideline = issue.guideline;
75
+ if (issue.resource) metadata.resource = issue.resource;
76
+ } else if (category === 'sast' || category === 'anti_patterns') {
77
+ if (issue.issue_confidence) metadata.confidence = issue.issue_confidence;
78
+ if (issue.issue_type || issue.test_type) metadata.issue_type = issue.issue_type || issue.test_type;
79
+ } else if (category === 'secrets') {
80
+ if (issue.type || issue.secret_type) metadata.secret_type = issue.type || issue.secret_type;
81
+ if (issue.confidence_score) metadata.confidence_score = issue.confidence_score;
82
+ } else if (category === 'dead_code') {
83
+ if (issue.type) metadata.type = issue.type;
84
+ if (issue.confidence) metadata.confidence = issue.confidence;
85
+ } else if (category === 'complex_functions') {
86
+ if (issue.complexity !== undefined) metadata.complexity = issue.complexity;
87
+ } else if (category === 'sca') {
88
+ if (issue.cvss_score !== undefined) metadata.cvss_score = issue.cvss_score;
89
+ if (issue.fix_version) metadata.fix_version = issue.fix_version;
90
+ }
91
+
92
+ const id = buildId(category, filePath, lineNumber, checkId, message);
93
+
94
+ return {
95
+ id,
96
+ category,
97
+ severity,
98
+ file_path: filePath,
99
+ line_number: lineNumber,
100
+ line_range: Array.isArray(lineRange) ? lineRange : [lineNumber],
101
+ check_id: checkId,
102
+ check_name: checkName,
103
+ message,
104
+ rule_id: checkId,
105
+ cwe: cwe ? String(cwe) : null,
106
+ cve: cve ? String(cve) : null,
107
+ package: packageInfo,
108
+ metadata,
109
+ dismissed: false,
110
+ dismiss_info: null,
111
+ };
112
+ }
113
+
114
+ /** Build the outer envelope object. */
115
+ export function buildEnvelope({
116
+ repo,
117
+ scan,
118
+ categories,
119
+ findings,
120
+ pagination,
121
+ filters,
122
+ errors,
123
+ }) {
124
+ const total = pagination.total;
125
+ const bySeverity = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 };
126
+ const byCategory = {};
127
+
128
+ for (const f of findings) {
129
+ bySeverity[f.severity] = (bySeverity[f.severity] ?? 0) + 1;
130
+ byCategory[f.category] = (byCategory[f.category] ?? 0) + 1;
131
+ }
132
+ // summary uses all findings (pre-pagination)
133
+ // recalculate from pre-pagination total
134
+ const allSeverity = { ...bySeverity };
135
+ const allCategory = { ...byCategory };
136
+
137
+ return {
138
+ schema_version: '1.0',
139
+ tool: 'codeant-cli',
140
+ tool_version: pkg.version,
141
+ generated_at: new Date().toISOString(),
142
+ repo,
143
+ scan,
144
+ categories,
145
+ summary: {
146
+ total,
147
+ by_severity: allSeverity,
148
+ by_category: allCategory,
149
+ },
150
+ pagination,
151
+ filters,
152
+ errors,
153
+ findings,
154
+ };
155
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Slice findings and return pagination metadata.
3
+ * @param {Array} findings - full (sorted, filtered) findings array
4
+ * @param {{ limit?: number, offset?: number }} opts
5
+ * @returns {{ items: Array, pagination: object }}
6
+ */
7
+ export function paginate(findings, { limit = 100, offset = 0 } = {}) {
8
+ const total = findings.length;
9
+ const safeOffset = Number.isFinite(offset) ? Math.max(0, Math.trunc(offset)) : 0;
10
+ const safeLimit = Number.isFinite(limit) && limit > 0 ? Math.trunc(limit) : 100;
11
+ const items = findings.slice(safeOffset, safeOffset + safeLimit);
12
+ return {
13
+ items,
14
+ pagination: {
15
+ limit: safeLimit,
16
+ offset: safeOffset,
17
+ returned: items.length,
18
+ total,
19
+ has_more: safeOffset + items.length < total,
20
+ },
21
+ };
22
+ }
@@ -0,0 +1,50 @@
1
+ import { getScanHistory } from '../../../scans/getScanHistory.js';
2
+
3
+ function withExitCode(code, message) {
4
+ const err = new Error(message);
5
+ err.exitCode = code;
6
+ return err;
7
+ }
8
+
9
+ /**
10
+ * Resolve the scan to use.
11
+ * Precedence: --scan (explicit SHA) > --branch (latest on branch) > global latest.
12
+ *
13
+ * @param {{ repo: string, scan?: string, branch?: string }} opts
14
+ * @returns {Promise<{ commit_id, branch, timestamp, status, resolved_by }>}
15
+ */
16
+ export async function resolveScan({ repo, scan, branch }) {
17
+ if (scan) {
18
+ const { success, scanHistory, error } = await getScanHistory(repo);
19
+ const history = success ? (scanHistory ?? []) : [];
20
+ const hit = history.find((s) => s.latest_commit_sha === scan);
21
+ return {
22
+ commit_id: scan,
23
+ branch: hit?.branch ?? branch ?? null,
24
+ timestamp: hit?.timestamp ?? null,
25
+ status: hit?.status ?? 'done',
26
+ resolved_by: 'explicit',
27
+ };
28
+ }
29
+
30
+ const { success, scanHistory, error } = await getScanHistory(repo);
31
+ if (!success) throw withExitCode(1, `scan history: ${error}`);
32
+
33
+ const sorted = [...(scanHistory ?? [])]
34
+ .filter((s) => s.latest_commit_sha && s.timestamp)
35
+ .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
36
+ const filtered = branch ? sorted.filter((s) => s.branch === branch) : sorted;
37
+ const pick = filtered[0];
38
+
39
+ if (!pick) {
40
+ throw withExitCode(2, branch ? `no scans for branch "${branch}"` : 'no scans found');
41
+ }
42
+
43
+ return {
44
+ commit_id: pick.latest_commit_sha,
45
+ branch: pick.branch ?? branch ?? null,
46
+ timestamp: pick.timestamp,
47
+ status: pick.status ?? 'done',
48
+ resolved_by: branch ? 'branch' : 'latest',
49
+ };
50
+ }
@@ -0,0 +1,19 @@
1
+ const SEV_RANK = { critical: 5, high: 4, medium: 3, low: 2, info: 1, unknown: 0 };
2
+
3
+ /**
4
+ * Deterministic sort: severity desc, file_path asc, line_number asc, check_id asc, id asc.
5
+ * Same input always produces byte-identical output.
6
+ */
7
+ export function deterministicSort(findings) {
8
+ return [...findings].sort((a, b) => {
9
+ const sd = (SEV_RANK[b.severity] ?? 0) - (SEV_RANK[a.severity] ?? 0);
10
+ if (sd !== 0) return sd;
11
+ const fp = (a.file_path ?? '').localeCompare(b.file_path ?? '', 'en', { sensitivity: 'base' });
12
+ if (fp !== 0) return fp;
13
+ const ln = (a.line_number ?? 0) - (b.line_number ?? 0);
14
+ if (ln !== 0) return ln;
15
+ const ci = (a.check_id ?? '').localeCompare(b.check_id ?? '', 'en');
16
+ if (ci !== 0) return ci;
17
+ return (a.id ?? '').localeCompare(b.id ?? '', 'en');
18
+ });
19
+ }
@@ -0,0 +1,15 @@
1
+ import { validateConnection } from '../../scans/connectionHandler.js';
2
+
3
+ /**
4
+ * codeant scans orgs
5
+ * List authenticated organizations.
6
+ */
7
+ export async function runOrgs() {
8
+ const result = await validateConnection();
9
+ if (!result.success) {
10
+ const err = new Error(result.error || 'Failed to validate connection');
11
+ err.exitCode = result.error?.toLowerCase().includes('network') ? 4 : 1;
12
+ throw err;
13
+ }
14
+ return { connections: result.connections, email: result.email };
15
+ }
@@ -0,0 +1,41 @@
1
+ import { validateConnection } from '../../scans/connectionHandler.js';
2
+ import { listRepos as listReposApi } from '../../scans/listRepos.js';
3
+
4
+ /**
5
+ * codeant scans repos [--org <org>]
6
+ * List repositories. Auto-picks org when only one is authenticated.
7
+ */
8
+ export async function runRepos({ org } = {}) {
9
+ let orgName = org;
10
+
11
+ if (!orgName) {
12
+ const conn = await validateConnection();
13
+ if (!conn.success) {
14
+ const err = new Error(conn.error || 'Failed to validate connection');
15
+ err.exitCode = 1;
16
+ throw err;
17
+ }
18
+ if (conn.connections.length === 0) {
19
+ const err = new Error('No authenticated organizations found');
20
+ err.exitCode = 1;
21
+ throw err;
22
+ }
23
+ if (conn.connections.length > 1) {
24
+ const err = new Error(
25
+ `Multiple orgs found. Specify one with --org. Available: ${conn.connections.map((c) => c.organizationName).join(', ')}`
26
+ );
27
+ err.exitCode = 1;
28
+ throw err;
29
+ }
30
+ orgName = conn.connections[0].organizationName;
31
+ }
32
+
33
+ const result = await listReposApi(orgName);
34
+ if (!result.success) {
35
+ const err = new Error(result.error || 'Failed to list repositories');
36
+ err.exitCode = 1;
37
+ throw err;
38
+ }
39
+
40
+ return { org: orgName, repos: result.repos };
41
+ }
@@ -0,0 +1,156 @@
1
+ import { resolveScan } from './lib/resolveScan.js';
2
+ import { parseTypes } from './lib/categories.js';
3
+ import { normalizeIssue, buildEnvelope } from './lib/normalize.js';
4
+ import { applyFilters } from './lib/filters.js';
5
+ import { deterministicSort } from './lib/sort.js';
6
+ import { paginate } from './lib/paginate.js';
7
+ import { emit } from './lib/emit.js';
8
+ import { progress, logError } from './lib/log.js';
9
+ import { FORMATTERS } from './formatters/index.js';
10
+ import { fetchDismissedAlerts } from '../../scans/fetchDismissedAlerts.js';
11
+
12
+ /**
13
+ * codeant scans results — full orchestration.
14
+ *
15
+ * @param {object} opts
16
+ */
17
+ export async function runResults(opts = {}) {
18
+ const {
19
+ repo,
20
+ scan,
21
+ branch,
22
+ types,
23
+ severity,
24
+ path: pathGlob,
25
+ check: checkRegex,
26
+ includeDismissed = false,
27
+ format = 'json',
28
+ output: outputPath = null,
29
+ fields = null,
30
+ limit = 100,
31
+ offset = 0,
32
+ failFast = false,
33
+ } = opts;
34
+
35
+ if (!repo) {
36
+ const err = new Error('--repo is required');
37
+ err.exitCode = 1;
38
+ throw err;
39
+ }
40
+
41
+ const formatter = FORMATTERS[format];
42
+ if (!formatter) {
43
+ const err = new Error(`Unknown --format "${format}". Valid: ${Object.keys(FORMATTERS).join(', ')}`);
44
+ err.exitCode = 1;
45
+ throw err;
46
+ }
47
+
48
+ // 1. Resolve scan
49
+ progress(`resolving scan for ${repo}…`);
50
+ const scanMeta = await resolveScan({ repo, scan, branch });
51
+ progress(`using commit ${scanMeta.commit_id} (${scanMeta.resolved_by})`);
52
+
53
+ // 2. Parse types
54
+ const categories = parseTypes(types);
55
+
56
+ // 3. Fetch in parallel + dismissed alerts
57
+ progress(`fetching ${categories.map((c) => c.key).join(', ')}…`);
58
+ const [settled, dismissedResult] = await Promise.all([
59
+ Promise.allSettled(categories.map((c) => c.fetcher(repo, scanMeta.commit_id))),
60
+ includeDismissed
61
+ ? Promise.resolve({ success: true, dismissedAlerts: [] })
62
+ : fetchDismissedAlerts(repo, 'security'),
63
+ ]);
64
+
65
+ const dismissedAlerts = dismissedResult.success ? (dismissedResult.dismissedAlerts ?? []) : [];
66
+
67
+ // 4. Collect findings + errors
68
+ const allFindings = [];
69
+ const errors = [];
70
+
71
+ for (let i = 0; i < settled.length; i++) {
72
+ const cat = categories[i];
73
+ const s = settled[i];
74
+
75
+ if (s.status === 'rejected' || !s.value?.success) {
76
+ const msg = s.status === 'rejected' ? s.reason?.message : s.value?.error;
77
+ errors.push({ category: cat.key, error: msg || 'unknown error' });
78
+ logError({ category: cat.key, error: msg || 'unknown error' });
79
+ if (failFast) {
80
+ const err = new Error(`Category "${cat.key}" failed: ${msg}`);
81
+ err.exitCode = 3;
82
+ throw err;
83
+ }
84
+ continue;
85
+ }
86
+
87
+ progress(`normalizing ${cat.key} (${s.value.issues?.length ?? 0} issues)…`);
88
+ for (const issue of s.value.issues ?? []) {
89
+ const f = normalizeIssue(issue, cat.key);
90
+ if (f) allFindings.push(f);
91
+ }
92
+ }
93
+
94
+ // 5. Filter
95
+ const severityList = severity
96
+ ? severity.split(',').map((s) => s.trim()).filter(Boolean)
97
+ : null;
98
+
99
+ const filtered = applyFilters(allFindings, {
100
+ severity: severityList,
101
+ pathGlob: pathGlob || null,
102
+ checkRegex: checkRegex || null,
103
+ dismissedAlerts,
104
+ includeDismissed,
105
+ });
106
+
107
+ // 6. Sort
108
+ const sorted = deterministicSort(filtered);
109
+
110
+ // 7. Paginate
111
+ const { items: pageItems, pagination } = paginate(sorted, { limit, offset });
112
+
113
+ // 8. Build envelope (summary uses pre-pagination totals)
114
+ const filtersObj = {
115
+ severity: severityList,
116
+ path: pathGlob || null,
117
+ check: checkRegex || null,
118
+ include_dismissed: includeDismissed,
119
+ };
120
+
121
+ // Rebuild summary from all filtered (pre-page) findings
122
+ const summaryBySev = { critical: 0, high: 0, medium: 0, low: 0, info: 0, unknown: 0 };
123
+ const summaryByCat = {};
124
+ for (const f of sorted) {
125
+ summaryBySev[f.severity] = (summaryBySev[f.severity] ?? 0) + 1;
126
+ summaryByCat[f.category] = (summaryByCat[f.category] ?? 0) + 1;
127
+ }
128
+
129
+ const envelope = buildEnvelope({
130
+ repo,
131
+ scan: scanMeta,
132
+ categories: categories.map((c) => c.key),
133
+ findings: pageItems,
134
+ pagination,
135
+ filters: filtersObj,
136
+ errors,
137
+ });
138
+ // Override summary with pre-pagination counts
139
+ envelope.summary = { total: sorted.length, by_severity: summaryBySev, by_category: summaryByCat };
140
+
141
+ // 9. Project fields
142
+ if (fields) {
143
+ const fieldList = fields.split(',').map((f) => f.trim()).filter(Boolean);
144
+ envelope.findings = envelope.findings.map((f) => {
145
+ const projected = {};
146
+ for (const key of fieldList) {
147
+ if (key in f) projected[key] = f[key];
148
+ }
149
+ return projected;
150
+ });
151
+ }
152
+
153
+ // 10. Render + emit
154
+ const rendered = formatter.render(envelope);
155
+ emit(rendered, outputPath, envelope.findings.length);
156
+ }