@aikidosec/ci-api-client 1.0.18 → 1.0.20

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.
@@ -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
+ }
@@ -0,0 +1,73 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import path from 'node:path';
3
+
4
+ // Drop coverage that points past the end of the source file.
5
+ // applied per-extension in their uploader and are not language-agnostic — we only
6
+ export async function loadSourceLineFixes(
7
+ repositoryRoot: string,
8
+ sourcePath: string
9
+ ): Promise<{ eof: number } | null> {
10
+ if (!repositoryRoot || !sourcePath) {
11
+ return null;
12
+ }
13
+
14
+ try {
15
+ const base = path.resolve(repositoryRoot);
16
+ const target = path.resolve(base, sourcePath);
17
+ const relative = path.relative(base, target);
18
+ if (relative.startsWith('..') || path.isAbsolute(relative)) {
19
+ return null;
20
+ }
21
+
22
+ // Do not follow symlinks: a tracked link can point at an unbounded
23
+ // special file (e.g. /dev/zero) and hang or exhaust memory on read.
24
+ const stats = await fs.lstat(target);
25
+ if (stats.isSymbolicLink() || !stats.isFile()) {
26
+ return null;
27
+ }
28
+
29
+ const content = await fs.readFile(target, 'utf8');
30
+ const lineCount = content.split(/\r?\n/).length;
31
+ return { eof: lineCount };
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ type TCoverageRecord = {
38
+ lines: Map<number, unknown>;
39
+ functions: Map<string, { line: number }>;
40
+ branches: Map<string, unknown>;
41
+ };
42
+
43
+ export function applySourceLineFixes(
44
+ record: TCoverageRecord,
45
+ lineFixes: { eof: number } | null
46
+ ): TCoverageRecord {
47
+ if (!lineFixes) {
48
+ return record;
49
+ }
50
+
51
+ const { eof } = lineFixes;
52
+
53
+ for (const lineNumber of record.lines.keys()) {
54
+ if (lineNumber < 1 || lineNumber > eof) {
55
+ record.lines.delete(lineNumber);
56
+ }
57
+ }
58
+
59
+ for (const [functionName, meta] of record.functions.entries()) {
60
+ if (meta.line < 1 || meta.line > eof) {
61
+ record.functions.delete(functionName);
62
+ }
63
+ }
64
+
65
+ for (const branchKey of record.branches.keys()) {
66
+ const lineNumber = Number(branchKey.split('\0')[0]);
67
+ if (lineNumber < 1 || lineNumber > eof) {
68
+ record.branches.delete(branchKey);
69
+ }
70
+ }
71
+
72
+ return record;
73
+ }
@@ -245,7 +245,7 @@ export const scan = async ({
245
245
  const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
246
246
  // Version provided to the API corresponds with the version in package.json
247
247
  // of the cli client
248
- const apiOptions: TScanApiOptions = { version: '1.0.18' };
248
+ const apiOptions: TScanApiOptions = { version: '1.0.20' };
249
249
  const cliOptions: TScanCliOptions = { pollInterval: 5 };
250
250
 
251
251
  if (userCliOptions.pullRequestTitle) {
@@ -269,7 +269,7 @@ export const scan = async ({
269
269
  const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
270
270
  // Version provided to the API corresponds with the version in package.json
271
271
  // of the cli client
272
- const apiOptions: TScanApiOptions = { version: '1.0.18' };
272
+ const apiOptions: TScanApiOptions = { version: '1.0.20' };
273
273
  const cliOptions: TScanCliOptions = { pollInterval: 10, json: false };
274
274
 
275
275
  if (userCliOptions.pullRequestTitle) {
@@ -0,0 +1,108 @@
1
+ import { Command } from 'commander';
2
+ import { Ora } from 'ora';
3
+ import { uploadCodeCoverage } from '../aikidoApi.js';
4
+ import { collectUploadPayload } from '../codeCoverage/collectUploadPayload.js';
5
+ import { getApiKey } from '../configuration.js';
6
+ import {
7
+ outputError,
8
+ outputHttpError,
9
+ outputLog,
10
+ startSpinner,
11
+ } from '../output.js';
12
+
13
+ type TUploadCoverageUserCliOptions = {
14
+ commitSha: string;
15
+ branchName: string;
16
+ filePaths: string[];
17
+ repositoryId: string | number;
18
+ };
19
+
20
+ async function cli(options: TUploadCoverageUserCliOptions) {
21
+ const apiKey = getApiKey();
22
+
23
+ if (!apiKey) {
24
+ outputError('Please set an api key using: aikido-api-client apikey <key>');
25
+ return;
26
+ }
27
+
28
+ const filePaths = normalizeFilePaths(options.filePaths);
29
+ if (filePaths.length === 0) {
30
+ outputError(
31
+ 'No code coverage file(s) provided. Specify at least one path with --file-paths.'
32
+ );
33
+ }
34
+
35
+ let loader: Ora | null = startSpinner(
36
+ 'Collecting coverage files and repository metadata'
37
+ );
38
+
39
+ let payload;
40
+ try {
41
+ payload = await collectUploadPayload(filePaths);
42
+ } catch (error) {
43
+ loader?.fail();
44
+ outputError(error instanceof Error ? error.message : String(error));
45
+ return;
46
+ }
47
+
48
+ loader?.succeed(
49
+ `Found ${payload.files.length} coverage file(s) (source paths=${payload.repository_source_paths.length}, eof=${Object.keys(payload.eof).length})`
50
+ );
51
+
52
+ for (const file of payload.files) {
53
+ outputLog(` ${file.filename} (${file.format})`);
54
+ }
55
+
56
+ loader = startSpinner('Uploading code coverage to Aikido');
57
+
58
+ try {
59
+ await uploadCodeCoverage({
60
+ repository_id: options.repositoryId,
61
+ commit_sha: options.commitSha,
62
+ branch_name: options.branchName,
63
+ repository_source_paths: payload.repository_source_paths,
64
+ eof: payload.eof,
65
+ files: payload.files,
66
+ });
67
+
68
+
69
+ loader?.succeed('Code coverage upload completed');
70
+ process.exit(0);
71
+ } catch (error) {
72
+ loader?.fail();
73
+ outputHttpError(error as any);
74
+ process.exit(1);
75
+ }
76
+ }
77
+
78
+ const normalizeFilePaths = (filePaths: string[]): string[] =>
79
+ filePaths
80
+ .flatMap((entry) => entry.split(/\n|\s+|,/))
81
+ .map((filePath) => filePath.trim())
82
+ .filter(Boolean);
83
+
84
+ export const cliSetup = (program: Command) =>
85
+ program
86
+ .command('upload-coverage')
87
+ .requiredOption(
88
+ '-c, --commit-sha <commitsha>',
89
+ 'The commit SHA for this CI run'
90
+ )
91
+ .requiredOption(
92
+ '-b, --branch-name <branchname>',
93
+ 'The branch name for this CI run'
94
+ )
95
+ .requiredOption(
96
+ '-f, --file-paths <paths...>',
97
+ 'Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.'
98
+ )
99
+ .requiredOption(
100
+ '-ri, --repository-id <repositoryid>',
101
+ 'The scm repository id'
102
+ )
103
+ .description(
104
+ 'Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).'
105
+ )
106
+ .action(cli);
107
+
108
+ export default { cli, cliSetup };
package/src/index.ts 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
 
10
11
  // Load all .env configuration variables and auto-inject them into process.env
11
12
  dotenv.config(
@@ -20,13 +21,14 @@ program
20
21
  .description(
21
22
  'CLI api client to easily integrate the Aikido public CI API into custom deploy scripts'
22
23
  )
23
- .version('1.0.18');
24
+ .version('1.0.20');
24
25
 
25
26
  // Load in all app commands and set them up in the `program` instance
26
27
  apiKey.cliSetup(program);
27
28
  scan.cliSetup(program);
28
29
  scanRelease.cliSetup(program);
29
30
  upload.cliSetup(program);
31
+ uploadCoverage.cliSetup(program);
30
32
 
31
33
  // Override global process.env type
32
34
  declare global {