@aikidosec/ci-api-client 1.0.17 → 1.0.19

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
@@ -49,49 +49,39 @@ Options:
49
49
  "HIGH", "CRITICAL")
50
50
  --poll-interval [interval] The poll interval when checking for an updated scan result (preset: 10)
51
51
  --sla-mode Let Aikido only fail only on open issues that have gone out of SLA.
52
+ --json Output the scan result as JSON (gate_passed and issue_breakdown)
52
53
  -h, --help display help for command
53
54
  ```
54
55
 
55
- ## Use case: pull request checks & gating
56
+ ## Use case: code coverage
56
57
 
57
- The example below shows how to use the 'scan' command. The use case here is to add a red/green check to a pull request based on the difference in files (head vs base commit).
58
+ The example below shows how to use the `upload-coverage` command. The use case here is to upload LCOV or Cobertura coverage reports from your CI pipeline so Aikido can track coverage per commit and branch.
58
59
 
59
60
  ```sh
60
- $ aikido-api-client scan <repository_id or repository_name> <base_commit_id> <head_commit_id>
61
+ $ aikido-api-client upload-coverage \
62
+ --repo-name <owner/repo> \
63
+ --commit-sha <commit_sha> \
64
+ --branch-name <branch_name> \
65
+ --repository-id <repository_id> \
66
+ --file-paths coverage/lcov.info
61
67
  ```
62
68
  ```
63
- Usage: Aikido API Client scan [options] <repository_id> <base_commit_id> <head_commit_id> [branch_name]
64
- Run a scan of an Aikido repo.
65
- Arguments:
66
- repository_id The internal GitHub/Gitlab/Bitbucket/.. repository id you want to scan.
67
- base_commit_id The base commit of the code you want to scan (e.g. the commit where you branched from for your PR or the
68
- initial commit of your repo)
69
- head_commit_id The latest commit you want to include in your scan (e.g. the latest commit id of your pull request)
70
- branch_name The branch name (default: "main")
69
+ Usage: Aikido API Client upload-coverage [options]
70
+ Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).
71
71
  Options:
72
- --pull-request-title <title> Your pull request title
73
- --pull-request-url <url> Your pull request URL
74
- --self-managed-scanners <scanners...> Set the minimum severity level. Accepted options are: LOW, MEDIUM, HIGH and CRITICAL. (choices: "checkov",
75
- "json-sbom")
76
- --expected-amount-json-sboms <amount> The expected amount of json sbombs
77
- --no-fail-on-dependency-scan Don't fail when scanning depedencies. Default is to fail on new CVE
78
- --fail-on-sast-scan Let Aikido fail when new static code analysis issues have been detected
79
- --fail-on-iac-scan Let Aikido fail when new infrastructure as code issues have been detected
80
- --fail-on-secrets-scan Let Aikido fail when new exposed secrets have been detected
81
- --minimum-severity-level <level> Set the minimum severity level. Accepted options are: LOW, MEDIUM, HIGH and CRITICAL. (choices: "LOW",
82
- "MEDIUM", "HIGH", "CRITICAL")
83
- --poll-interval [interval] The poll interval when checking for an updated scan result (preset: 5)
84
- --base-branch <branchname> Optional: base branch for the release gated scan.
85
- -h, --help display help for command
72
+ -r, --repo-name <reponame> Repository name as owner/repo (e.g. org/my-repo)
73
+ -c, --commit-sha <commitsha> The commit SHA for this CI run
74
+ -b, --branch-name <branchname> The branch name for this CI run
75
+ -f, --file-paths <paths...> Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.
76
+ -ri, --repository-id <repositoryid> The scm repository id
77
+ -h, --help display help for command
86
78
  ```
87
79
 
88
-
89
-
90
80
  ## How it works
91
81
 
92
82
  The CLI will spawn a cloud-based scan and then report scan progress. It will exit with exitCode `0` if the scan was successfull (`gate_passed: true`). The process will exit with exitCode `10` if the scan was unsuccesfull (`gate_passed: false`). If anything else goes wrong (e.g. API unavailable, scanning unavailable, other unexpected issue) the process will exit with exitCode `1`.
93
83
 
94
- If you want the scan to run quietly (without output), you can add the `--quiet` option to the command.
84
+ If you want the scan to run quietly (without output), you can add the `--quiet` option to the command. If you want machine-readable output instead, add `--json` to `scan-release`. That prints a JSON object with `gate_passed` and `issue_breakdown` and skips spinners and other extra info.
95
85
 
96
86
  Please note that the repository_id which you need to provide to the CLI is the unique ID of the Git provider you are using, not the ID of the repository in Aikido. You can also find this ID in Aikido, by going to the repository's detail page and clicking on the Git provider's icon in the header. Alternatively, you can pass the repository name as it is defined in Aikido.
97
87
 
@@ -30,8 +30,24 @@ export type TPollIsScanningResult = {
30
30
  secrets_scan_completed: boolean;
31
31
  sbom_scan_completed?: boolean;
32
32
  };
33
+ export type TIssueSeverityBreakdown = {
34
+ critical: number;
35
+ high: number;
36
+ medium: number;
37
+ low: number;
38
+ };
39
+ export type TIssueBreakdown = {
40
+ sast: TIssueSeverityBreakdown;
41
+ sca: TIssueSeverityBreakdown;
42
+ iac: TIssueSeverityBreakdown;
43
+ secrets: TIssueSeverityBreakdown;
44
+ malware: TIssueSeverityBreakdown;
45
+ license: TIssueSeverityBreakdown;
46
+ code_quality: TIssueSeverityBreakdown;
47
+ };
33
48
  type TPollScanCompletedOptions = {
34
49
  gate_passed: boolean;
50
+ issue_breakdown?: TIssueBreakdown;
35
51
  };
36
52
  type TPollScanFeatureBranchCompletedOptions = {
37
53
  new_issues_found: number;
@@ -63,4 +79,19 @@ export type TUploadResult = {
63
79
  success: number;
64
80
  };
65
81
  export declare function uploadCustomScanResult(data: TUploadApiOptions): Promise<TUploadResult>;
82
+ export type TUploadCodeCoverageFile = {
83
+ filename: string;
84
+ format: 'lcov' | 'cobertura';
85
+ content: string;
86
+ };
87
+ export type TUploadCodeCoverageOptions = {
88
+ repository_id: string | number;
89
+ repo_name: string;
90
+ commit_sha: string;
91
+ branch_name: string;
92
+ repository_source_paths: string[];
93
+ eof: Record<string, number>;
94
+ files: TUploadCodeCoverageFile[];
95
+ };
96
+ export declare function uploadCodeCoverage(data: TUploadCodeCoverageOptions): Promise<any>;
66
97
  export {};
package/lib/aikidoApi.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import axios from 'axios';
2
+ import { gzipSync } from 'node:zlib';
2
3
  import { getApiKey } from './configuration.js';
3
4
  import { outputDebug } from './output.js';
4
5
  const getApiUrl = () => {
@@ -62,3 +63,38 @@ export async function uploadCustomScanResult(data) {
62
63
  outputDebug(resultData);
63
64
  return resultData;
64
65
  }
66
+ export async function uploadCodeCoverage(data) {
67
+ const requestUrl = `${getApiUrl()}/api/integrations/continuous_integration/scan/code_coverage`;
68
+ const requestConfig = {
69
+ method: 'POST',
70
+ data: {
71
+ repository_id: data.repository_id,
72
+ repo_name: data.repo_name,
73
+ commit_sha: data.commit_sha,
74
+ branch_name: data.branch_name,
75
+ repository_source_paths: data.repository_source_paths,
76
+ eof: data.eof,
77
+ files: data.files.map((file) => ({
78
+ filename: file.filename,
79
+ format: file.format,
80
+ content: gzipSync(file.content).toString('base64'),
81
+ })),
82
+ },
83
+ headers: getApiHeaders(),
84
+ };
85
+ outputDebug(`API request: ${requestUrl}`);
86
+ outputDebug({
87
+ ...requestConfig,
88
+ data: {
89
+ ...requestConfig.data,
90
+ files: requestConfig.data.files.map((file) => ({
91
+ filename: file.filename,
92
+ format: file.format,
93
+ content: `[base64 gzip, ${file.content.length} chars]`,
94
+ })),
95
+ },
96
+ });
97
+ const resultData = (await axios(requestUrl, requestConfig)).data;
98
+ outputDebug(resultData);
99
+ return resultData;
100
+ }
@@ -0,0 +1,12 @@
1
+ import { TCoverageFormat } from './reportPaths.js';
2
+ export type TCoverageFilePayload = {
3
+ filename: string;
4
+ format: TCoverageFormat;
5
+ content: string;
6
+ };
7
+ export type TCollectUploadPayloadResult = {
8
+ repository_source_paths: string[];
9
+ eof: Record<string, number>;
10
+ files: TCoverageFilePayload[];
11
+ };
12
+ export declare function collectUploadPayload(filePaths: string[]): Promise<TCollectUploadPayloadResult>;
@@ -0,0 +1,54 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { validateFilePath } from './paths.js';
4
+ import { loadProjectFiles } from './projectFiles.js';
5
+ import { detectFormatFromFilename, extractCoveredSourcePaths, } from './reportPaths.js';
6
+ import { loadSourceLineFixes } from './sourceLineFixes.js';
7
+ export async function collectUploadPayload(filePaths) {
8
+ if (filePaths.length === 0) {
9
+ throw new Error('No code coverage file(s) provided. Specify at least one path.');
10
+ }
11
+ const project = await loadProjectFiles();
12
+ if (!project || project.files.length === 0) {
13
+ throw new Error('No source files found in this repository. ' +
14
+ 'Run this command from a checked-out repository before uploading coverage.');
15
+ }
16
+ const repositoryRoot = project.root;
17
+ const files = [];
18
+ const coveredPaths = new Set();
19
+ for (const inputPath of filePaths) {
20
+ validateFilePath(inputPath);
21
+ const absolutePath = path.resolve(inputPath);
22
+ const stats = await fs.lstat(absolutePath);
23
+ const realPath = await fs.realpath(absolutePath);
24
+ const relativePath = path.relative(repositoryRoot, realPath);
25
+ if (stats.isSymbolicLink() ||
26
+ !stats.isFile() ||
27
+ relativePath.startsWith('..') ||
28
+ path.isAbsolute(relativePath)) {
29
+ throw new Error(`Coverage file must be a regular file inside the repository: ${inputPath}`);
30
+ }
31
+ const content = await fs.readFile(realPath, 'utf8');
32
+ const format = detectFormatFromFilename(inputPath);
33
+ for (const sourcePath of extractCoveredSourcePaths(content, format, repositoryRoot)) {
34
+ coveredPaths.add(sourcePath);
35
+ }
36
+ files.push({
37
+ filename: path.posix.basename(inputPath.replace(/\\/g, '/')),
38
+ format,
39
+ content,
40
+ });
41
+ }
42
+ const eof = {};
43
+ for (const sourcePath of coveredPaths) {
44
+ const fixes = await loadSourceLineFixes(repositoryRoot, sourcePath);
45
+ if (fixes?.eof != null) {
46
+ eof[sourcePath] = fixes.eof;
47
+ }
48
+ }
49
+ return {
50
+ repository_source_paths: project.files,
51
+ eof,
52
+ files,
53
+ };
54
+ }
@@ -0,0 +1,4 @@
1
+ export declare function validateFilePath(filePath: string): void;
2
+ export declare function isAbsoluteSourcePath(sourcePath: string): boolean;
3
+ export declare function normalizePathSeparators(sourcePath: string): string;
4
+ export declare function normalizeSourcePath(sourcePath: string, repositoryRoot: string): string;
@@ -0,0 +1,38 @@
1
+ import path from 'node:path';
2
+ export function validateFilePath(filePath) {
3
+ if (filePath.includes('..') || isAbsoluteSourcePath(filePath)) {
4
+ throw new Error('Invalid file path: absolute paths and ".." segments are not allowed');
5
+ }
6
+ }
7
+ export function isAbsoluteSourcePath(sourcePath) {
8
+ const trimmedPath = sourcePath.trim();
9
+ const pathInput = normalizePathSeparators(trimmedPath);
10
+ const windowsPath = /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
11
+ trimmedPath.startsWith('\\\\') ||
12
+ trimmedPath.startsWith('//');
13
+ const pathApi = windowsPath ? path.win32 : path.posix;
14
+ return pathApi.isAbsolute(pathInput);
15
+ }
16
+ export function normalizePathSeparators(sourcePath) {
17
+ return sourcePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
18
+ }
19
+ export function normalizeSourcePath(sourcePath, repositoryRoot) {
20
+ const trimmedPath = sourcePath.trim();
21
+ const pathInput = normalizePathSeparators(trimmedPath);
22
+ const windowsPath = /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
23
+ trimmedPath.startsWith('\\\\') ||
24
+ trimmedPath.startsWith('//');
25
+ const pathApi = windowsPath ? path.win32 : path.posix;
26
+ const absolutePath = pathApi.isAbsolute(pathInput);
27
+ const normalizedPath = absolutePath
28
+ ? pathApi.relative(repositoryRoot, pathInput)
29
+ : pathInput;
30
+ if (absolutePath &&
31
+ (!normalizedPath ||
32
+ normalizedPath === '..' ||
33
+ normalizedPath.startsWith(`..${pathApi.sep}`) ||
34
+ pathApi.isAbsolute(normalizedPath))) {
35
+ throw new Error(`Invalid source path outside the repository: ${sourcePath}`);
36
+ }
37
+ return normalizePathSeparators(normalizedPath);
38
+ }
@@ -0,0 +1,7 @@
1
+ export type TProjectFiles = {
2
+ root: string;
3
+ files: string[];
4
+ };
5
+ export declare function loadProjectFiles(): Promise<TProjectFiles | null>;
6
+ export declare function pathStem(sourcePath: string): string;
7
+ export declare function createPathResolver(projectFiles: string[]): (coveragePath: string) => string | null;
@@ -0,0 +1,233 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import ignore from 'ignore';
4
+ const HARDCODED_IGNORES = [
5
+ '.git/',
6
+ 'node_modules/',
7
+ 'vendor/',
8
+ 'coverage/',
9
+ 'dist/',
10
+ 'build/',
11
+ '.venv/',
12
+ 'venv/',
13
+ '__pycache__/',
14
+ '.eggs/',
15
+ '*.egg-info/',
16
+ '.tox/',
17
+ '.mypy_cache/',
18
+ '.pytest_cache/',
19
+ '.next/',
20
+ '.nuxt/',
21
+ '.turbo/',
22
+ '.cache/',
23
+ 'bower_components/',
24
+ '*.png',
25
+ '*.gif',
26
+ '*.jpg',
27
+ '*.jpeg',
28
+ '*.webp',
29
+ '*.ico',
30
+ '*.woff',
31
+ '*.woff2',
32
+ '*.ttf',
33
+ '*.eot',
34
+ '*.mp4',
35
+ '*.mp3',
36
+ '.nyc_output/',
37
+ 'jspm_packages/',
38
+ '.yarn/cache/',
39
+ '.pnpm-store/',
40
+ 'target/',
41
+ 'Pods/',
42
+ ];
43
+ export async function loadProjectFiles() {
44
+ const root = await findGitRoot(process.cwd());
45
+ const ignoreManager = ignore().add(HARDCODED_IGNORES);
46
+ try {
47
+ ignoreManager.add(await fs.readFile(path.join(root, '.gitignore'), 'utf8'));
48
+ }
49
+ catch {
50
+ }
51
+ const files = [];
52
+ await collectFiles(root, '', [{ base: '', ig: ignoreManager }], files);
53
+ const projectFiles = files.filter(isLikelySourceFile);
54
+ return projectFiles.length === 0 ? null : { root, files: projectFiles };
55
+ }
56
+ async function findGitRoot(startDir) {
57
+ const start = path.resolve(startDir);
58
+ let dir = start;
59
+ while (path.dirname(dir) !== dir) {
60
+ try {
61
+ await fs.access(path.join(dir, '.git'));
62
+ return dir;
63
+ }
64
+ catch {
65
+ dir = path.dirname(dir);
66
+ }
67
+ }
68
+ try {
69
+ await fs.access(path.join(dir, '.git'));
70
+ return dir;
71
+ }
72
+ catch {
73
+ return start;
74
+ }
75
+ }
76
+ function isLikelySourceFile(relativePath) {
77
+ const base = relativePath.slice(relativePath.lastIndexOf('/') + 1);
78
+ const lower = base.toLowerCase();
79
+ if (lower === '.gitignore' ||
80
+ lower.endsWith('.lcov') ||
81
+ lower === 'lcov.info' ||
82
+ (lower.endsWith('.info') && lower.includes('lcov')) ||
83
+ lower === 'coverage-final.json' ||
84
+ lower === 'clover.xml' ||
85
+ lower === 'cobertura.xml' ||
86
+ (lower.endsWith('.xml') && lower.includes('cobertura'))) {
87
+ return false;
88
+ }
89
+ return true;
90
+ }
91
+ async function collectFiles(absoluteDir, relativeDir, ignoreStack, files) {
92
+ let stack = ignoreStack;
93
+ if (relativeDir) {
94
+ try {
95
+ const nested = ignore().add(await fs.readFile(path.join(absoluteDir, '.gitignore'), 'utf8'));
96
+ stack = [...ignoreStack, { base: relativeDir, ig: nested }];
97
+ }
98
+ catch {
99
+ }
100
+ }
101
+ let entries;
102
+ try {
103
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
104
+ }
105
+ catch {
106
+ return;
107
+ }
108
+ for (const entry of entries) {
109
+ const relativePath = relativeDir
110
+ ? `${relativeDir}/${entry.name}`
111
+ : entry.name;
112
+ if (isIgnored(relativePath, entry.isDirectory(), stack)) {
113
+ continue;
114
+ }
115
+ if (entry.isDirectory()) {
116
+ await collectFiles(path.join(absoluteDir, entry.name), relativePath, stack, files);
117
+ }
118
+ else if (entry.isFile() || entry.isSymbolicLink()) {
119
+ files.push(relativePath);
120
+ }
121
+ }
122
+ }
123
+ function isIgnored(relativePath, isDirectory, stack) {
124
+ let ignored = false;
125
+ for (const { base, ig } of stack) {
126
+ const testPath = base ? relativePath.slice(base.length + 1) : relativePath;
127
+ if (!testPath) {
128
+ continue;
129
+ }
130
+ const result = ignoreDecision(ig, testPath, isDirectory);
131
+ if (result.ignored) {
132
+ ignored = true;
133
+ }
134
+ else if (result.unignored) {
135
+ ignored = false;
136
+ }
137
+ }
138
+ return ignored;
139
+ }
140
+ function ignoreDecision(ig, testPath, isDirectory) {
141
+ const result = ig.test(testPath);
142
+ if (isDirectory) {
143
+ const directoryResult = ig.test(`${testPath}/`);
144
+ if (directoryResult.ignored || directoryResult.unignored) {
145
+ return directoryResult;
146
+ }
147
+ }
148
+ return result;
149
+ }
150
+ export function pathStem(sourcePath) {
151
+ const lastSlashIndex = sourcePath.lastIndexOf('/');
152
+ const lastDotIndex = sourcePath.lastIndexOf('.');
153
+ if (lastDotIndex > lastSlashIndex) {
154
+ return sourcePath.slice(0, lastDotIndex);
155
+ }
156
+ return sourcePath;
157
+ }
158
+ function normalizePath(filePath) {
159
+ return filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
160
+ }
161
+ function isSamePathOrSuffix(fullPath, possibleSuffix) {
162
+ const normalizedFullPath = fullPath.toLowerCase();
163
+ const suffix = possibleSuffix.toLowerCase();
164
+ return (normalizedFullPath === suffix ||
165
+ normalizedFullPath.endsWith(`/${suffix}`));
166
+ }
167
+ function countSharedTrailingSegments(leftPath, rightPath) {
168
+ const leftSegments = leftPath.toLowerCase().split('/');
169
+ const rightSegments = rightPath.toLowerCase().split('/');
170
+ const maxShared = Math.min(leftSegments.length, rightSegments.length);
171
+ let sharedCount = 0;
172
+ while (sharedCount < maxShared &&
173
+ leftSegments[leftSegments.length - 1 - sharedCount] ===
174
+ rightSegments[rightSegments.length - 1 - sharedCount]) {
175
+ sharedCount++;
176
+ }
177
+ return sharedCount;
178
+ }
179
+ function pickBestMatch(coveragePath, candidates) {
180
+ let best = null;
181
+ let bestShared = -1;
182
+ let bestLength = Infinity;
183
+ let ambiguous = false;
184
+ for (const candidate of candidates) {
185
+ const shared = countSharedTrailingSegments(coveragePath, candidate);
186
+ if (shared > bestShared ||
187
+ (shared === bestShared && candidate.length < bestLength)) {
188
+ best = candidate;
189
+ bestShared = shared;
190
+ bestLength = candidate.length;
191
+ ambiguous = false;
192
+ }
193
+ else if (shared === bestShared && candidate.length === bestLength) {
194
+ ambiguous = true;
195
+ }
196
+ }
197
+ return ambiguous ? null : best;
198
+ }
199
+ export function createPathResolver(projectFiles) {
200
+ const byBasename = new Map();
201
+ for (const projectPath of projectFiles) {
202
+ const key = projectPath
203
+ .slice(projectPath.lastIndexOf('/') + 1)
204
+ .toLowerCase();
205
+ const entries = byBasename.get(key);
206
+ if (entries) {
207
+ entries.push(projectPath);
208
+ }
209
+ else {
210
+ byBasename.set(key, [projectPath]);
211
+ }
212
+ }
213
+ return (coveragePath) => {
214
+ const normalizedCoveragePath = normalizePath(coveragePath);
215
+ const key = normalizedCoveragePath
216
+ .slice(normalizedCoveragePath.lastIndexOf('/') + 1)
217
+ .toLowerCase();
218
+ const candidates = byBasename.get(key) ?? [];
219
+ const matches = candidates.filter((projectPath) => isSamePathOrSuffix(projectPath, normalizedCoveragePath) ||
220
+ isSamePathOrSuffix(normalizedCoveragePath, projectPath));
221
+ if (matches.length === 0) {
222
+ return null;
223
+ }
224
+ const exactMatch = matches.find((projectPath) => projectPath === normalizedCoveragePath);
225
+ if (exactMatch) {
226
+ return exactMatch;
227
+ }
228
+ if (matches.length === 1) {
229
+ return matches[0];
230
+ }
231
+ return pickBestMatch(normalizedCoveragePath, matches);
232
+ };
233
+ }
@@ -0,0 +1,3 @@
1
+ export type TCoverageFormat = 'lcov' | 'cobertura';
2
+ export declare function detectFormatFromFilename(filename: string): TCoverageFormat;
3
+ export declare function extractCoveredSourcePaths(content: string, format: TCoverageFormat, repositoryRoot: string): string[];
@@ -0,0 +1,34 @@
1
+ import { normalizePathSeparators, normalizeSourcePath } from './paths.js';
2
+ export function detectFormatFromFilename(filename) {
3
+ const lower = normalizePathSeparators(filename).toLowerCase();
4
+ const base = lower.slice(lower.lastIndexOf('/') + 1);
5
+ if (base.endsWith('.lcov') || base.endsWith('.info') || base.includes('lcov')) {
6
+ return 'lcov';
7
+ }
8
+ if (base.endsWith('.xml') || base.includes('cobertura')) {
9
+ return 'cobertura';
10
+ }
11
+ throw new Error(`Could not detect coverage format from filename "${filename}". ` +
12
+ 'Use a name like lcov.info, *.lcov, or *cobertura*.xml.');
13
+ }
14
+ export function extractCoveredSourcePaths(content, format, repositoryRoot) {
15
+ if (format === 'cobertura') {
16
+ return extractCoberturaFilenames(content, repositoryRoot);
17
+ }
18
+ return extractLcovFilenames(content, repositoryRoot);
19
+ }
20
+ function extractLcovFilenames(content, repositoryRoot) {
21
+ return [...content.matchAll(/^SF:([^\r\n]+)$/gm)]
22
+ .map((match) => normalizeSourcePath(match[1], repositoryRoot))
23
+ .filter(Boolean);
24
+ }
25
+ function extractCoberturaFilenames(content, repositoryRoot) {
26
+ const paths = [];
27
+ for (const match of content.matchAll(/\bfilename\s*=\s*"([^"]+)"/gi)) {
28
+ const normalized = normalizeSourcePath(match[1], repositoryRoot);
29
+ if (normalized) {
30
+ paths.push(normalized);
31
+ }
32
+ }
33
+ return paths;
34
+ }
@@ -0,0 +1,14 @@
1
+ export declare function loadSourceLineFixes(repositoryRoot: string, sourcePath: string): Promise<{
2
+ eof: number;
3
+ } | null>;
4
+ type TCoverageRecord = {
5
+ lines: Map<number, unknown>;
6
+ functions: Map<string, {
7
+ line: number;
8
+ }>;
9
+ branches: Map<string, unknown>;
10
+ };
11
+ export declare function applySourceLineFixes(record: TCoverageRecord, lineFixes: {
12
+ eof: number;
13
+ } | null): TCoverageRecord;
14
+ export {};
@@ -0,0 +1,48 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ export async function loadSourceLineFixes(repositoryRoot, sourcePath) {
4
+ if (!repositoryRoot || !sourcePath) {
5
+ return null;
6
+ }
7
+ try {
8
+ const base = path.resolve(repositoryRoot);
9
+ const target = path.resolve(base, sourcePath);
10
+ const relative = path.relative(base, target);
11
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
12
+ return null;
13
+ }
14
+ const stats = await fs.lstat(target);
15
+ if (stats.isSymbolicLink() || !stats.isFile()) {
16
+ return null;
17
+ }
18
+ const content = await fs.readFile(target, 'utf8');
19
+ const lineCount = content.split(/\r?\n/).length;
20
+ return { eof: lineCount };
21
+ }
22
+ catch {
23
+ return null;
24
+ }
25
+ }
26
+ export function applySourceLineFixes(record, lineFixes) {
27
+ if (!lineFixes) {
28
+ return record;
29
+ }
30
+ const { eof } = lineFixes;
31
+ for (const lineNumber of record.lines.keys()) {
32
+ if (lineNumber < 1 || lineNumber > eof) {
33
+ record.lines.delete(lineNumber);
34
+ }
35
+ }
36
+ for (const [functionName, meta] of record.functions.entries()) {
37
+ if (meta.line < 1 || meta.line > eof) {
38
+ record.functions.delete(functionName);
39
+ }
40
+ }
41
+ for (const branchKey of record.branches.keys()) {
42
+ const lineNumber = Number(branchKey.split('\0')[0]);
43
+ if (lineNumber < 1 || lineNumber > eof) {
44
+ record.branches.delete(branchKey);
45
+ }
46
+ }
47
+ return record;
48
+ }
@@ -118,7 +118,7 @@ export const scan = async ({ repoId, baseCommitId, headCommitId, branchName, opt
118
118
  pollStatus();
119
119
  };
120
120
  const parseCliOptions = (userCliOptions) => {
121
- const apiOptions = { version: '1.0.16' };
121
+ const apiOptions = { version: '1.0.18' };
122
122
  const cliOptions = { pollInterval: 5 };
123
123
  if (userCliOptions.pullRequestTitle) {
124
124
  apiOptions.pull_request_metadata = {
@@ -25,6 +25,7 @@ type TScanUserCliOptions = {
25
25
  pollInterval?: number;
26
26
  baseBranch?: string;
27
27
  slaMode?: boolean;
28
+ json?: boolean;
28
29
  };
29
30
  declare function cli(repoId: string, commitId: string, options: TScanUserCliOptions, command: string): Promise<void>;
30
31
  export declare const scan: ({ repoId, commitId, options, pollInterval, onStart, onStartComplete, onStartFail, onScanStart, onNextPoll, onScanComplete, onScanFail, }: TScanArguments) => Promise<void>;