@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 +18 -28
- package/lib/aikidoApi.d.ts +31 -0
- package/lib/aikidoApi.js +36 -0
- package/lib/codeCoverage/collectUploadPayload.d.ts +12 -0
- package/lib/codeCoverage/collectUploadPayload.js +54 -0
- package/lib/codeCoverage/paths.d.ts +4 -0
- package/lib/codeCoverage/paths.js +38 -0
- package/lib/codeCoverage/projectFiles.d.ts +7 -0
- package/lib/codeCoverage/projectFiles.js +233 -0
- package/lib/codeCoverage/reportPaths.d.ts +3 -0
- package/lib/codeCoverage/reportPaths.js +34 -0
- package/lib/codeCoverage/sourceLineFixes.d.ts +14 -0
- package/lib/codeCoverage/sourceLineFixes.js +48 -0
- package/lib/commands/scan.js +1 -1
- package/lib/commands/scanRelease.d.ts +1 -0
- package/lib/commands/scanRelease.js +21 -3
- package/lib/commands/uploadCoverage.d.ts +15 -0
- package/lib/commands/uploadCoverage.js +63 -0
- package/lib/index.js +3 -1
- package/lib/output.d.ts +1 -0
- package/lib/output.js +3 -0
- package/npm-shrinkwrap.json +17 -9
- package/package.json +3 -2
- package/src/aikidoApi.ts +77 -0
- package/src/codeCoverage/collectUploadPayload.ts +93 -0
- package/src/codeCoverage/paths.ts +62 -0
- package/src/codeCoverage/projectFiles.ts +342 -0
- package/src/codeCoverage/reportPaths.ts +63 -0
- package/src/codeCoverage/sourceLineFixes.ts +73 -0
- package/src/commands/scan.ts +1 -1
- package/src/commands/scanRelease.ts +33 -2
- package/src/commands/uploadCoverage.ts +115 -0
- package/src/index.ts +3 -1
- package/src/output.ts +7 -0
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
package/src/commands/scan.ts
CHANGED
|
@@ -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.
|
|
248
|
+
const apiOptions: TScanApiOptions = { version: '1.0.18' };
|
|
249
249
|
const cliOptions: TScanCliOptions = { pollInterval: 5 };
|
|
250
250
|
|
|
251
251
|
if (userCliOptions.pullRequestTitle) {
|
|
@@ -14,6 +14,7 @@ import { getApiKey } from '../configuration.js';
|
|
|
14
14
|
import {
|
|
15
15
|
outputError,
|
|
16
16
|
outputHttpError,
|
|
17
|
+
outputJson,
|
|
17
18
|
outputLog,
|
|
18
19
|
startSpinner,
|
|
19
20
|
} from '../output.js';
|
|
@@ -49,6 +50,7 @@ type TScanArguments = {
|
|
|
49
50
|
|
|
50
51
|
type TScanCliOptions = {
|
|
51
52
|
pollInterval: number;
|
|
53
|
+
json: boolean;
|
|
52
54
|
};
|
|
53
55
|
|
|
54
56
|
type TScanUserCliOptions = {
|
|
@@ -63,6 +65,7 @@ type TScanUserCliOptions = {
|
|
|
63
65
|
pollInterval?: number;
|
|
64
66
|
baseBranch?: string;
|
|
65
67
|
slaMode?: boolean;
|
|
68
|
+
json?: boolean;
|
|
66
69
|
};
|
|
67
70
|
|
|
68
71
|
async function cli(
|
|
@@ -83,6 +86,13 @@ async function cli(
|
|
|
83
86
|
let loader: Ora | null;
|
|
84
87
|
let pollCount: number = 1;
|
|
85
88
|
|
|
89
|
+
// when the json option is added, only output the json result.
|
|
90
|
+
// Disabling console output takes care of the spinners and other extra info
|
|
91
|
+
const jsonOutput = cliOptions.json;
|
|
92
|
+
if (jsonOutput) {
|
|
93
|
+
process.env.QUIET = true;
|
|
94
|
+
}
|
|
95
|
+
|
|
86
96
|
// Setup different scan() event handlers
|
|
87
97
|
const onStart = () => {
|
|
88
98
|
loader = startSpinner('Starting Aikido Security release scan');
|
|
@@ -107,6 +117,20 @@ async function cli(
|
|
|
107
117
|
};
|
|
108
118
|
|
|
109
119
|
const onScanComplete = (pollResult: any) => {
|
|
120
|
+
if (jsonOutput) {
|
|
121
|
+
outputJson({
|
|
122
|
+
gate_passed: pollResult.gate_passed === true,
|
|
123
|
+
issue_breakdown: pollResult.issue_breakdown,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
if (pollResult.gate_passed !== true) {
|
|
127
|
+
// Don't process.exit() here: with piped stdout (CI) the JSON write
|
|
128
|
+
// may still be queued and a forced exit can truncate it.
|
|
129
|
+
process.exitCode = 10;
|
|
130
|
+
}
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
110
134
|
if (pollResult.gate_passed === true) {
|
|
111
135
|
loader?.succeed('Scan completed, no open issues found');
|
|
112
136
|
if (pollResult.diff_url) {
|
|
@@ -245,8 +269,8 @@ export const scan = async ({
|
|
|
245
269
|
const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
|
|
246
270
|
// Version provided to the API corresponds with the version in package.json
|
|
247
271
|
// of the cli client
|
|
248
|
-
const apiOptions: TScanApiOptions = { version: '1.0.
|
|
249
|
-
const cliOptions: TScanCliOptions = { pollInterval: 10 };
|
|
272
|
+
const apiOptions: TScanApiOptions = { version: '1.0.18' };
|
|
273
|
+
const cliOptions: TScanCliOptions = { pollInterval: 10, json: false };
|
|
250
274
|
|
|
251
275
|
if (userCliOptions.pullRequestTitle) {
|
|
252
276
|
apiOptions.pull_request_metadata = {
|
|
@@ -292,6 +316,9 @@ const parseCliOptions = (userCliOptions: TScanUserCliOptions) => {
|
|
|
292
316
|
} else if (userCliOptions.pollInterval) {
|
|
293
317
|
cliOptions.pollInterval = userCliOptions.pollInterval;
|
|
294
318
|
}
|
|
319
|
+
if (userCliOptions.json) {
|
|
320
|
+
cliOptions.json = true;
|
|
321
|
+
}
|
|
295
322
|
|
|
296
323
|
return { apiOptions, cliOptions };
|
|
297
324
|
};
|
|
@@ -363,6 +390,10 @@ export const cliSetup = (program: Command) =>
|
|
|
363
390
|
'--sla-mode',
|
|
364
391
|
'Let Aikido only fail only on open issues that have gone out of SLA.'
|
|
365
392
|
)
|
|
393
|
+
.option(
|
|
394
|
+
'--json',
|
|
395
|
+
'Output the scan result as JSON (gate_passed and issue_breakdown)'
|
|
396
|
+
)
|
|
366
397
|
.description('Run a release scan of an Aikido repo.')
|
|
367
398
|
.action(cli);
|
|
368
399
|
|
|
@@ -0,0 +1,115 @@
|
|
|
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
|
+
repoName: string;
|
|
15
|
+
commitSha: string;
|
|
16
|
+
branchName: string;
|
|
17
|
+
filePaths: string[];
|
|
18
|
+
repositoryId: string | number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
async function cli(options: TUploadCoverageUserCliOptions) {
|
|
22
|
+
const apiKey = getApiKey();
|
|
23
|
+
|
|
24
|
+
if (!apiKey) {
|
|
25
|
+
outputError('Please set an api key using: aikido-api-client apikey <key>');
|
|
26
|
+
return;
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const filePaths = normalizeFilePaths(options.filePaths);
|
|
31
|
+
if (filePaths.length === 0) {
|
|
32
|
+
outputError(
|
|
33
|
+
'No code coverage file(s) provided. Specify at least one path with --file-paths.'
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let loader: Ora | null = startSpinner(
|
|
38
|
+
'Collecting coverage files and repository metadata'
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
let payload;
|
|
42
|
+
try {
|
|
43
|
+
payload = await collectUploadPayload(filePaths);
|
|
44
|
+
} catch (error) {
|
|
45
|
+
loader?.fail();
|
|
46
|
+
outputError(error instanceof Error ? error.message : String(error));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
loader?.succeed(
|
|
51
|
+
`Found ${payload.files.length} coverage file(s) (source paths=${payload.repository_source_paths.length}, eof=${Object.keys(payload.eof).length})`
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
for (const file of payload.files) {
|
|
55
|
+
outputLog(` ${file.filename} (${file.format})`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
loader = startSpinner('Uploading code coverage to Aikido');
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
await uploadCodeCoverage({
|
|
62
|
+
repository_id: options.repositoryId,
|
|
63
|
+
repo_name: options.repoName,
|
|
64
|
+
commit_sha: options.commitSha,
|
|
65
|
+
branch_name: options.branchName,
|
|
66
|
+
repository_source_paths: payload.repository_source_paths,
|
|
67
|
+
eof: payload.eof,
|
|
68
|
+
files: payload.files,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
loader?.succeed('Code coverage upload completed');
|
|
73
|
+
process.exit(0);
|
|
74
|
+
} catch (error) {
|
|
75
|
+
loader?.fail();
|
|
76
|
+
outputHttpError(error as any);
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const normalizeFilePaths = (filePaths: string[]): string[] =>
|
|
82
|
+
filePaths
|
|
83
|
+
.flatMap((entry) => entry.split(/\n|\s+|,/))
|
|
84
|
+
.map((filePath) => filePath.trim())
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
|
|
87
|
+
export const cliSetup = (program: Command) =>
|
|
88
|
+
program
|
|
89
|
+
.command('upload-coverage')
|
|
90
|
+
.requiredOption(
|
|
91
|
+
'-r, --repo-name <reponame>',
|
|
92
|
+
'Repository name as owner/repo (e.g. org/my-repo)'
|
|
93
|
+
)
|
|
94
|
+
.requiredOption(
|
|
95
|
+
'-c, --commit-sha <commitsha>',
|
|
96
|
+
'The commit SHA for this CI run'
|
|
97
|
+
)
|
|
98
|
+
.requiredOption(
|
|
99
|
+
'-b, --branch-name <branchname>',
|
|
100
|
+
'The branch name for this CI run'
|
|
101
|
+
)
|
|
102
|
+
.requiredOption(
|
|
103
|
+
'-f, --file-paths <paths...>',
|
|
104
|
+
'Path(s) to LCOV or Cobertura coverage report(s). Format is detected from each filename.'
|
|
105
|
+
)
|
|
106
|
+
.requiredOption(
|
|
107
|
+
'-ri, --repository-id <repositoryid>',
|
|
108
|
+
'The scm repository id'
|
|
109
|
+
)
|
|
110
|
+
.description(
|
|
111
|
+
'Upload LCOV or Cobertura coverage reports to Aikido (with repository_source_paths and EOF metadata).'
|
|
112
|
+
)
|
|
113
|
+
.action(cli);
|
|
114
|
+
|
|
115
|
+
export default { cli, cliSetup };
|