@aikidosec/ci-api-client 1.0.18 → 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/lib/index.js CHANGED
@@ -6,6 +6,7 @@ import apiKey from './commands/apiKey.js';
6
6
  import scan from './commands/scan.js';
7
7
  import scanRelease from './commands/scanRelease.js';
8
8
  import upload from './commands/upload.js';
9
+ import uploadCoverage from './commands/uploadCoverage.js';
9
10
  dotenv.config(process.env.NODE_ENV ? { path: `.env.${process.env.NODE_ENV}` } : {});
10
11
  export const program = new Command();
11
12
  program
@@ -16,6 +17,7 @@ apiKey.cliSetup(program);
16
17
  scan.cliSetup(program);
17
18
  scanRelease.cliSetup(program);
18
19
  upload.cliSetup(program);
20
+ uploadCoverage.cliSetup(program);
19
21
  program
20
22
  .option('-q, --quiet', 'Disable console output when executing commands')
21
23
  .on('option:quiet', function () {
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@aikidosec/ci-api-client",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@aikidosec/ci-api-client",
9
- "version": "1.0.18",
9
+ "version": "1.0.19",
10
10
  "license": "MIT",
11
11
  "dependencies": {
12
12
  "axios": "1.18.0",
@@ -14,6 +14,7 @@
14
14
  "commander": "11.0.0",
15
15
  "configstore": "6.0.0",
16
16
  "dotenv": "16.3.1",
17
+ "ignore": "7.0.8",
17
18
  "ora": "6.3.1"
18
19
  },
19
20
  "bin": {
@@ -780,6 +781,15 @@
780
781
  ],
781
782
  "license": "BSD-3-Clause"
782
783
  },
784
+ "node_modules/ignore": {
785
+ "version": "7.0.8",
786
+ "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.8.tgz",
787
+ "integrity": "sha512-YYNsSlXBjMk92SKnkwvB5LOVSa6OznlFUGcsvrFgNJbJCd0M1XKeFVRc8ZByeCqz32FivYNHJVooLmdqrmvp/Q==",
788
+ "license": "MIT",
789
+ "engines": {
790
+ "node": ">= 4"
791
+ }
792
+ },
783
793
  "node_modules/ignore-by-default": {
784
794
  "version": "1.0.1",
785
795
  "dev": true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aikidosec/ci-api-client",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "description": "CLI api client to easily integrate the Aikido public CI API into custom deploy scripts",
5
5
  "license": "MIT",
6
6
  "author": "Bert Devriese <bert@builtinbruges.com>",
@@ -30,6 +30,7 @@
30
30
  "commander": "11.0.0",
31
31
  "configstore": "6.0.0",
32
32
  "dotenv": "16.3.1",
33
+ "ignore": "7.0.8",
33
34
  "ora": "6.3.1"
34
35
  },
35
36
  "devDependencies": {
package/src/aikidoApi.ts 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
 
@@ -186,3 +187,61 @@ export async function uploadCustomScanResult(
186
187
 
187
188
  return resultData;
188
189
  }
190
+ // #endregion
191
+
192
+ // #region Upload code coverage
193
+ export type TUploadCodeCoverageFile = {
194
+ filename: string;
195
+ format: 'lcov' | 'cobertura';
196
+ content: string;
197
+ };
198
+
199
+ export type TUploadCodeCoverageOptions = {
200
+ repository_id: string | number;
201
+ repo_name: string;
202
+ commit_sha: string;
203
+ branch_name: string;
204
+ repository_source_paths: string[];
205
+ eof: Record<string, number>;
206
+ files: TUploadCodeCoverageFile[];
207
+ };
208
+
209
+
210
+ export async function uploadCodeCoverage(data: TUploadCodeCoverageOptions){
211
+ const requestUrl = `${getApiUrl()}/api/integrations/continuous_integration/scan/code_coverage`;
212
+ const requestConfig = {
213
+ method: 'POST',
214
+ data: {
215
+ repository_id: data.repository_id,
216
+ repo_name: data.repo_name,
217
+ commit_sha: data.commit_sha,
218
+ branch_name: data.branch_name,
219
+ repository_source_paths: data.repository_source_paths,
220
+ eof: data.eof,
221
+ files: data.files.map((file) => ({
222
+ filename: file.filename,
223
+ format: file.format,
224
+ content: gzipSync(file.content).toString('base64'),
225
+ })),
226
+ },
227
+ headers: getApiHeaders(),
228
+ };
229
+
230
+ outputDebug(`API request: ${requestUrl}`);
231
+ outputDebug({
232
+ ...requestConfig,
233
+ data: {
234
+ ...requestConfig.data,
235
+ files: requestConfig.data.files.map((file) => ({
236
+ filename: file.filename,
237
+ format: file.format,
238
+ content: `[base64 gzip, ${file.content.length} chars]`,
239
+ })),
240
+ },
241
+ });
242
+
243
+ const resultData = (await axios(requestUrl, requestConfig)).data;
244
+ outputDebug(resultData);
245
+
246
+ return resultData;
247
+ }
@@ -0,0 +1,93 @@
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 {
6
+ TCoverageFormat,
7
+ detectFormatFromFilename,
8
+ extractCoveredSourcePaths,
9
+ } from './reportPaths.js';
10
+ import { loadSourceLineFixes } from './sourceLineFixes.js';
11
+
12
+ export type TCoverageFilePayload = {
13
+ filename: string;
14
+ format: TCoverageFormat;
15
+ content: string;
16
+ };
17
+
18
+ export type TCollectUploadPayloadResult = {
19
+ repository_source_paths: string[];
20
+ eof: Record<string, number>;
21
+ files: TCoverageFilePayload[];
22
+ };
23
+
24
+ /**
25
+ * Read coverage files and collect repository_source_paths + EOF metadata
26
+ * for backend processing. Mirrors AikidoSec/code-coverage-github-action.
27
+ */
28
+ export async function collectUploadPayload(
29
+ filePaths: string[]
30
+ ): Promise<TCollectUploadPayloadResult> {
31
+ if (filePaths.length === 0) {
32
+ throw new Error(
33
+ 'No code coverage file(s) provided. Specify at least one path.'
34
+ );
35
+ }
36
+
37
+ const project = await loadProjectFiles();
38
+ if (!project || project.files.length === 0) {
39
+ throw new Error(
40
+ 'No source files found in this repository. ' +
41
+ 'Run this command from a checked-out repository before uploading coverage.'
42
+ );
43
+ }
44
+
45
+ const repositoryRoot = project.root;
46
+ const files: TCoverageFilePayload[] = [];
47
+ const coveredPaths = new Set<string>();
48
+
49
+ for (const inputPath of filePaths) {
50
+ validateFilePath(inputPath);
51
+
52
+ const absolutePath = path.resolve(inputPath);
53
+ const stats = await fs.lstat(absolutePath);
54
+ const realPath = await fs.realpath(absolutePath);
55
+ const relativePath = path.relative(repositoryRoot, realPath);
56
+ if (
57
+ stats.isSymbolicLink() ||
58
+ !stats.isFile() ||
59
+ relativePath.startsWith('..') ||
60
+ path.isAbsolute(relativePath)
61
+ ) {
62
+ throw new Error(
63
+ `Coverage file must be a regular file inside the repository: ${inputPath}`
64
+ );
65
+ }
66
+ const content = await fs.readFile(realPath, 'utf8');
67
+ const format = detectFormatFromFilename(inputPath);
68
+
69
+ for (const sourcePath of extractCoveredSourcePaths(content, format, repositoryRoot)) {
70
+ coveredPaths.add(sourcePath);
71
+ }
72
+
73
+ files.push({
74
+ filename: path.posix.basename(inputPath.replace(/\\/g, '/')),
75
+ format,
76
+ content,
77
+ });
78
+ }
79
+
80
+ const eof: Record<string, number> = {};
81
+ for (const sourcePath of coveredPaths) {
82
+ const fixes = await loadSourceLineFixes(repositoryRoot, sourcePath);
83
+ if (fixes?.eof != null) {
84
+ eof[sourcePath] = fixes.eof;
85
+ }
86
+ }
87
+
88
+ return {
89
+ repository_source_paths: project.files,
90
+ eof,
91
+ files,
92
+ };
93
+ }
@@ -0,0 +1,62 @@
1
+ import path from 'node:path';
2
+
3
+ /**
4
+ * Validate that a file path is safe to read.
5
+ * Rejects absolute paths and paths containing '..' segments to prevent
6
+ * directory traversal and arbitrary file access.
7
+ */
8
+ export function validateFilePath(filePath: string): void {
9
+ if (filePath.includes('..') || isAbsoluteSourcePath(filePath)) {
10
+ throw new Error(
11
+ 'Invalid file path: absolute paths and ".." segments are not allowed'
12
+ );
13
+ }
14
+ }
15
+
16
+ export function isAbsoluteSourcePath(sourcePath: string): boolean {
17
+ const trimmedPath = sourcePath.trim();
18
+ const pathInput = normalizePathSeparators(trimmedPath);
19
+
20
+ const windowsPath =
21
+ /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
22
+ trimmedPath.startsWith('\\\\') ||
23
+ trimmedPath.startsWith('//');
24
+ const pathApi = windowsPath ? path.win32 : path.posix;
25
+
26
+ return pathApi.isAbsolute(pathInput);
27
+ }
28
+
29
+ /** Trim, use forward slashes, drop a leading `./`. */
30
+ export function normalizePathSeparators(sourcePath: string): string {
31
+ return sourcePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
32
+ }
33
+
34
+ export function normalizeSourcePath(
35
+ sourcePath: string,
36
+ repositoryRoot: string
37
+ ): string {
38
+ const trimmedPath = sourcePath.trim();
39
+ const pathInput = normalizePathSeparators(trimmedPath);
40
+
41
+ const windowsPath =
42
+ /^[a-zA-Z]:[\\/]/.test(trimmedPath) ||
43
+ trimmedPath.startsWith('\\\\') ||
44
+ trimmedPath.startsWith('//');
45
+ const pathApi = windowsPath ? path.win32 : path.posix;
46
+ const absolutePath = pathApi.isAbsolute(pathInput);
47
+ const normalizedPath = absolutePath
48
+ ? pathApi.relative(repositoryRoot, pathInput)
49
+ : pathInput;
50
+
51
+ if (
52
+ absolutePath &&
53
+ (!normalizedPath ||
54
+ normalizedPath === '..' ||
55
+ normalizedPath.startsWith(`..${pathApi.sep}`) ||
56
+ pathApi.isAbsolute(normalizedPath))
57
+ ) {
58
+ throw new Error(`Invalid source path outside the repository: ${sourcePath}`);
59
+ }
60
+
61
+ return normalizePathSeparators(normalizedPath);
62
+ }
@@ -0,0 +1,342 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+ import ignore, { Ignore } from 'ignore';
4
+
5
+ // Always skip these, even if .gitignore does not list them.
6
+ const HARDCODED_IGNORES = [
7
+ '.git/',
8
+ 'node_modules/',
9
+ 'vendor/',
10
+ 'coverage/',
11
+ 'dist/',
12
+ 'build/',
13
+ '.venv/',
14
+ 'venv/',
15
+ '__pycache__/',
16
+ '.eggs/',
17
+ '*.egg-info/',
18
+ '.tox/',
19
+ '.mypy_cache/',
20
+ '.pytest_cache/',
21
+ '.next/',
22
+ '.nuxt/',
23
+ '.turbo/',
24
+ '.cache/',
25
+ 'bower_components/',
26
+ '*.png',
27
+ '*.gif',
28
+ '*.jpg',
29
+ '*.jpeg',
30
+ '*.webp',
31
+ '*.ico',
32
+ '*.woff',
33
+ '*.woff2',
34
+ '*.ttf',
35
+ '*.eot',
36
+ '*.mp4',
37
+ '*.mp3',
38
+ '.nyc_output/',
39
+ 'jspm_packages/',
40
+ '.yarn/cache/',
41
+ '.pnpm-store/',
42
+ 'target/', // Java/Rust
43
+ 'Pods/', // Swift
44
+ ];
45
+
46
+ type TIgnoreStackEntry = {
47
+ base: string;
48
+ ig: Ignore;
49
+ };
50
+
51
+ export type TProjectFiles = {
52
+ root: string;
53
+ files: string[];
54
+ };
55
+
56
+ // Walk the repo and return relative file paths, applying hardcoded ignores + .gitignore.
57
+ export async function loadProjectFiles(): Promise<TProjectFiles | null> {
58
+ const root = await findGitRoot(process.cwd());
59
+ const ignoreManager = ignore().add(HARDCODED_IGNORES);
60
+
61
+ try {
62
+ ignoreManager.add(await fs.readFile(path.join(root, '.gitignore'), 'utf8'));
63
+ } catch {
64
+ // No root .gitignore — hardcoded rules still apply.
65
+ }
66
+
67
+ const files: string[] = [];
68
+ await collectFiles(root, '', [{ base: '', ig: ignoreManager }], files);
69
+
70
+ const projectFiles = files.filter(isLikelySourceFile);
71
+ return projectFiles.length === 0 ? null : { root, files: projectFiles };
72
+ }
73
+
74
+ async function findGitRoot(startDir: string): Promise<string> {
75
+ const start = path.resolve(startDir);
76
+ let dir = start;
77
+
78
+ while (path.dirname(dir) !== dir) {
79
+ try {
80
+ await fs.access(path.join(dir, '.git'));
81
+ return dir;
82
+ } catch {
83
+ dir = path.dirname(dir);
84
+ }
85
+ }
86
+
87
+ try {
88
+ await fs.access(path.join(dir, '.git'));
89
+ return dir;
90
+ } catch {
91
+ return start;
92
+ }
93
+ }
94
+
95
+ // Skip coverage report files and .gitignore — not useful as source matches.
96
+ // Not in HARDCODED_IGNORES because these often live at the repo root and are not gitignored.
97
+ function isLikelySourceFile(relativePath: string): boolean {
98
+ const base = relativePath.slice(relativePath.lastIndexOf('/') + 1);
99
+ const lower = base.toLowerCase();
100
+
101
+ if (
102
+ lower === '.gitignore' ||
103
+ lower.endsWith('.lcov') ||
104
+ lower === 'lcov.info' ||
105
+ (lower.endsWith('.info') && lower.includes('lcov')) ||
106
+ lower === 'coverage-final.json' ||
107
+ lower === 'clover.xml' ||
108
+ lower === 'cobertura.xml' ||
109
+ (lower.endsWith('.xml') && lower.includes('cobertura'))
110
+ ) {
111
+ return false;
112
+ }
113
+
114
+ return true;
115
+ }
116
+
117
+ // Recursively list files. ignoreStack is root → nested .gitignore rules for this path.
118
+ async function collectFiles(
119
+ absoluteDir: string,
120
+ relativeDir: string,
121
+ ignoreStack: TIgnoreStackEntry[],
122
+ files: string[]
123
+ ): Promise<void> {
124
+ let stack = ignoreStack;
125
+
126
+ if (relativeDir) {
127
+ try {
128
+ const nested = ignore().add(
129
+ await fs.readFile(path.join(absoluteDir, '.gitignore'), 'utf8')
130
+ );
131
+ stack = [...ignoreStack, { base: relativeDir, ig: nested }];
132
+ } catch {
133
+ // No nested .gitignore for this directory.
134
+ }
135
+ }
136
+
137
+ let entries;
138
+ try {
139
+ entries = await fs.readdir(absoluteDir, { withFileTypes: true });
140
+ } catch {
141
+ return;
142
+ }
143
+
144
+ for (const entry of entries) {
145
+ const relativePath = relativeDir
146
+ ? `${relativeDir}/${entry.name}`
147
+ : entry.name;
148
+
149
+ if (isIgnored(relativePath, entry.isDirectory(), stack)) {
150
+ continue;
151
+ }
152
+
153
+ if (entry.isDirectory()) {
154
+ await collectFiles(
155
+ path.join(absoluteDir, entry.name),
156
+ relativePath,
157
+ stack,
158
+ files
159
+ );
160
+ } else if (entry.isFile() || entry.isSymbolicLink()) {
161
+ files.push(relativePath);
162
+ }
163
+ }
164
+ }
165
+
166
+ // Git last-match: walk root → nested; a later matching rule (including !negation) wins.
167
+ function isIgnored(
168
+ relativePath: string,
169
+ isDirectory: boolean,
170
+ stack: TIgnoreStackEntry[]
171
+ ): boolean {
172
+ let ignored = false;
173
+
174
+ for (const { base, ig } of stack) {
175
+ const testPath = base ? relativePath.slice(base.length + 1) : relativePath;
176
+
177
+ if (!testPath) {
178
+ continue;
179
+ }
180
+
181
+ const result = ignoreDecision(ig, testPath, isDirectory);
182
+
183
+ if (result.ignored) {
184
+ ignored = true;
185
+ } else if (result.unignored) {
186
+ ignored = false;
187
+ }
188
+ }
189
+
190
+ return ignored;
191
+ }
192
+
193
+ // Directory patterns end with "/"; node-ignore treats "foo" as a file unless we also test "foo/".
194
+ function ignoreDecision(
195
+ ig: Ignore,
196
+ testPath: string,
197
+ isDirectory: boolean
198
+ ): { ignored: boolean; unignored: boolean } {
199
+ const result = ig.test(testPath);
200
+
201
+ if (isDirectory) {
202
+ const directoryResult = ig.test(`${testPath}/`);
203
+ if (directoryResult.ignored || directoryResult.unignored) {
204
+ return directoryResult;
205
+ }
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ // Drop the file extension: "src/foo.ts" → "src/foo".
212
+ export function pathStem(sourcePath: string): string {
213
+ const lastSlashIndex = sourcePath.lastIndexOf('/');
214
+ const lastDotIndex = sourcePath.lastIndexOf('.');
215
+
216
+ if (lastDotIndex > lastSlashIndex) {
217
+ return sourcePath.slice(0, lastDotIndex);
218
+ }
219
+
220
+ return sourcePath;
221
+ }
222
+
223
+ function normalizePath(filePath: string): string {
224
+ return filePath.trim().replace(/\\/g, '/').replace(/^\.\//, '');
225
+ }
226
+
227
+ // True if paths are equal, or one ends with the other after a "/".
228
+ // e.g. "app/src/util.js" matches "src/util.js"; "src/utils.js" does not match "util.js".
229
+ function isSamePathOrSuffix(fullPath: string, possibleSuffix: string): boolean {
230
+ const normalizedFullPath = fullPath.toLowerCase();
231
+ const suffix = possibleSuffix.toLowerCase();
232
+ return (
233
+ normalizedFullPath === suffix ||
234
+ normalizedFullPath.endsWith(`/${suffix}`)
235
+ );
236
+ }
237
+
238
+ // Count matching segments from the end: "x/a/b.js" and "y/a/b.js" → 2.
239
+ // needed for picking the best match when there are multiple candidates
240
+ function countSharedTrailingSegments(
241
+ leftPath: string,
242
+ rightPath: string
243
+ ): number {
244
+ const leftSegments = leftPath.toLowerCase().split('/');
245
+ const rightSegments = rightPath.toLowerCase().split('/');
246
+ const maxShared = Math.min(leftSegments.length, rightSegments.length);
247
+ let sharedCount = 0;
248
+
249
+ while (
250
+ sharedCount < maxShared &&
251
+ leftSegments[leftSegments.length - 1 - sharedCount] ===
252
+ rightSegments[rightSegments.length - 1 - sharedCount]
253
+ ) {
254
+ sharedCount++;
255
+ }
256
+
257
+ return sharedCount;
258
+ }
259
+
260
+ // Prefer more shared trailing segments, then the shorter path. Tie → null (ambiguous).
261
+ // needed for picking the best match when there are multiple candidates
262
+ function pickBestMatch(
263
+ coveragePath: string,
264
+ candidates: string[]
265
+ ): string | null {
266
+ let best: string | null = null;
267
+ let bestShared = -1;
268
+ let bestLength = Infinity;
269
+ let ambiguous = false;
270
+
271
+ for (const candidate of candidates) {
272
+ const shared = countSharedTrailingSegments(coveragePath, candidate);
273
+
274
+ if (
275
+ shared > bestShared ||
276
+ (shared === bestShared && candidate.length < bestLength)
277
+ ) {
278
+ best = candidate;
279
+ bestShared = shared;
280
+ bestLength = candidate.length;
281
+ ambiguous = false;
282
+ } else if (shared === bestShared && candidate.length === bestLength) {
283
+ ambiguous = true;
284
+ }
285
+ }
286
+
287
+ return ambiguous ? null : best;
288
+ }
289
+
290
+ // Map a coverage report path (LCOV SF:) to a real project file. No match → null (drop it).
291
+ export function createPathResolver(
292
+ projectFiles: string[]
293
+ ): (coveragePath: string) => string | null {
294
+ const byBasename = new Map<string, string[]>();
295
+
296
+ // group by basename
297
+ for (const projectPath of projectFiles) {
298
+ const key = projectPath
299
+ .slice(projectPath.lastIndexOf('/') + 1)
300
+ .toLowerCase();
301
+ const entries = byBasename.get(key);
302
+ if (entries) {
303
+ entries.push(projectPath);
304
+ } else {
305
+ byBasename.set(key, [projectPath]);
306
+ }
307
+ }
308
+
309
+ return (coveragePath: string) => {
310
+ const normalizedCoveragePath = normalizePath(coveragePath);
311
+ const key = normalizedCoveragePath
312
+ .slice(normalizedCoveragePath.lastIndexOf('/') + 1)
313
+ .toLowerCase();
314
+
315
+ // find all files with the same basename
316
+ const candidates = byBasename.get(key) ?? [];
317
+
318
+ // filter by path suffix
319
+ const matches = candidates.filter(
320
+ (projectPath) =>
321
+ isSamePathOrSuffix(projectPath, normalizedCoveragePath) ||
322
+ isSamePathOrSuffix(normalizedCoveragePath, projectPath)
323
+ );
324
+
325
+ if (matches.length === 0) {
326
+ return null;
327
+ }
328
+
329
+ const exactMatch = matches.find(
330
+ (projectPath) => projectPath === normalizedCoveragePath
331
+ );
332
+ if (exactMatch) {
333
+ return exactMatch;
334
+ }
335
+
336
+ if (matches.length === 1) {
337
+ return matches[0];
338
+ }
339
+
340
+ return pickBestMatch(normalizedCoveragePath, matches);
341
+ };
342
+ }
@@ -0,0 +1,63 @@
1
+ import { normalizePathSeparators, normalizeSourcePath } from './paths.js';
2
+
3
+ export type TCoverageFormat = 'lcov' | 'cobertura';
4
+
5
+ /**
6
+ * Detect coverage format from the report filename only.
7
+ */
8
+ export function detectFormatFromFilename(filename: string): TCoverageFormat {
9
+ const lower = normalizePathSeparators(filename).toLowerCase();
10
+ const base = lower.slice(lower.lastIndexOf('/') + 1);
11
+
12
+ if (base.endsWith('.lcov') || base.endsWith('.info') || base.includes('lcov')) {
13
+ return 'lcov';
14
+ }
15
+
16
+ if (base.endsWith('.xml') || base.includes('cobertura')) {
17
+ return 'cobertura';
18
+ }
19
+
20
+ throw new Error(
21
+ `Could not detect coverage format from filename "${filename}". ` +
22
+ 'Use a name like lcov.info, *.lcov, or *cobertura*.xml.'
23
+ );
24
+ }
25
+
26
+ /**
27
+ * Extract source file paths referenced in a coverage report (for EOF map).
28
+ */
29
+ export function extractCoveredSourcePaths(
30
+ content: string,
31
+ format: TCoverageFormat,
32
+ repositoryRoot: string
33
+ ): string[] {
34
+ if (format === 'cobertura') {
35
+ return extractCoberturaFilenames(content, repositoryRoot);
36
+ }
37
+
38
+ return extractLcovFilenames(content, repositoryRoot);
39
+ }
40
+
41
+ function extractLcovFilenames(
42
+ content: string,
43
+ repositoryRoot: string
44
+ ): string[] {
45
+ return [...content.matchAll(/^SF:([^\r\n]+)$/gm)]
46
+ .map((match) => normalizeSourcePath(match[1], repositoryRoot))
47
+ .filter(Boolean);
48
+ }
49
+
50
+ function extractCoberturaFilenames(
51
+ content: string,
52
+ repositoryRoot: string
53
+ ): string[] {
54
+ const paths: string[] = [];
55
+ for (const match of content.matchAll(/\bfilename\s*=\s*"([^"]+)"/gi)) {
56
+ const normalized = normalizeSourcePath(match[1], repositoryRoot);
57
+ if (normalized) {
58
+ paths.push(normalized);
59
+ }
60
+ }
61
+
62
+ return paths;
63
+ }