Skip to content

Commit 7fbad87

Browse files
feat: implement functionality to output the scorecard results to JSON
1 parent f5d92e6 commit 7fbad87

File tree

4 files changed

+134
-3
lines changed

4 files changed

+134
-3
lines changed
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
import { writeFileSync } from 'node:fs';
2+
import { logger, getLineColLocation } from '@redocly/openapi-core';
3+
import { blue, green } from 'colorette';
4+
import { formatPath, getExecutionTime } from '../../../utils/miscellaneous.js';
5+
6+
import type { ScorecardProblem } from '../types.js';
7+
8+
type ScorecardLevel = {
9+
summary: {
10+
errors: number;
11+
warnings: number;
12+
};
13+
problems: Array<{
14+
ruleId: string;
15+
ruleUrl?: string;
16+
severity: string;
17+
message: string;
18+
location: {
19+
file: string;
20+
range: string;
21+
pointer?: string;
22+
}[];
23+
}>;
24+
};
25+
26+
export type ScorecardJsonOutput = Record<string, ScorecardLevel>;
27+
28+
function formatRange(
29+
start: { line: number; col: number },
30+
end?: { line: number; col: number }
31+
): string {
32+
const startStr = `Line ${start.line}, Col ${start.col}`;
33+
if (!end) {
34+
return startStr;
35+
}
36+
const endStr = `Line ${end.line}, Col ${end.col}`;
37+
return `${startStr}${endStr}`;
38+
}
39+
40+
function getRuleUrl(ruleId: string): string | undefined {
41+
if (!ruleId.includes('/')) {
42+
return `https://redocly.com/docs/cli/rules/oas/${ruleId}.md`;
43+
}
44+
return undefined;
45+
}
46+
47+
function stripAnsiCodes(text: string): string {
48+
// eslint-disable-next-line no-control-regex
49+
return text.replace(/\u001b\[\d+m/g, '');
50+
}
51+
52+
export function exportScorecardResultsToJson(
53+
path: string,
54+
problems: ScorecardProblem[],
55+
outputPath: string
56+
): void {
57+
const startedAt = performance.now();
58+
const groupedByLevel: Record<string, ScorecardProblem[]> = {};
59+
60+
for (const problem of problems) {
61+
const level = problem.scorecardLevel || 'Unknown';
62+
if (!groupedByLevel[level]) {
63+
groupedByLevel[level] = [];
64+
}
65+
groupedByLevel[level].push(problem);
66+
}
67+
68+
const output: ScorecardJsonOutput = {};
69+
70+
for (const [levelName, levelProblems] of Object.entries(groupedByLevel)) {
71+
let errors = 0;
72+
let warnings = 0;
73+
74+
const formattedProblems = levelProblems.map((problem) => {
75+
if (problem.severity === 'error') errors++;
76+
if (problem.severity === 'warn') warnings++;
77+
78+
return {
79+
ruleId: problem.ruleId,
80+
ruleUrl: getRuleUrl(problem.ruleId),
81+
severity: problem.severity,
82+
message: stripAnsiCodes(problem.message),
83+
84+
location: problem.location.map((loc) => {
85+
const lineCol = getLineColLocation(loc);
86+
return {
87+
file: loc.source.absoluteRef,
88+
range: formatRange(lineCol.start, lineCol.end),
89+
pointer: loc.pointer,
90+
};
91+
}),
92+
};
93+
});
94+
95+
output[levelName] = {
96+
summary: {
97+
errors,
98+
warnings,
99+
},
100+
problems: formattedProblems,
101+
};
102+
}
103+
104+
try {
105+
writeFileSync(outputPath, JSON.stringify(output, null, 2), 'utf-8');
106+
const elapsed = getExecutionTime(startedAt);
107+
logger.info(
108+
`📊 Scorecard results for ${blue(formatPath(path))} at ${blue(
109+
outputPath || 'stdout'
110+
)} ${green(elapsed)}.\n`
111+
);
112+
} catch (error) {
113+
logger.info(
114+
`❌ Errors encountered while bundling ${blue(
115+
formatPath(path)
116+
)}: bundle not created (use --force to ignore errors).\n`
117+
);
118+
}
119+
}

packages/cli/src/commands/scorecard-classic/index.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
1-
import { getFallbackApisOrExit } from '../../utils/miscellaneous.js';
1+
import { formatPath, getFallbackApisOrExit } from '../../utils/miscellaneous.js';
22
import { BaseResolver, bundle, logger } from '@redocly/openapi-core';
33
import { exitWithError } from '../../utils/error.js';
44
import { handleLoginAndFetchToken } from './auth/login-handler.js';
55
import { printScorecardResults } from './formatters/stylish-formatter.js';
6+
import { exportScorecardResultsToJson } from './formatters/json-formatter.js';
67
import { fetchRemoteScorecardAndPlugins } from './remote/fetch-scorecard.js';
78
import { validateScorecard } from './validation/validate-scorecard.js';
9+
import { gray } from 'colorette';
810

911
import type { ScorecardClassicArgv } from './types.js';
1012
import type { CommandArgs } from '../../wrapper.js';
@@ -37,7 +39,7 @@ export async function handleScorecardClassic({ argv, config }: CommandArgs<Score
3739
);
3840
}
3941

40-
logger.info(`\nRunning Scorecard Classic...\n`);
42+
logger.info(gray(`\nRunning scorecard for ${formatPath(path)}...\n`));
4143
const result = await validateScorecard(
4244
document,
4345
externalRefResolver,
@@ -46,5 +48,9 @@ export async function handleScorecardClassic({ argv, config }: CommandArgs<Score
4648
remoteScorecardAndPlugins?.plugins
4749
);
4850

49-
printScorecardResults(result, path);
51+
if (argv.output) {
52+
exportScorecardResultsToJson(path, result, argv.output);
53+
} else {
54+
printScorecardResults(result, path);
55+
}
5056
}

packages/cli/src/commands/scorecard-classic/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export type ScorecardClassicArgv = {
55
api: string;
66
config: string;
77
'project-url'?: string;
8+
output?: string;
89
} & VerifyConfigOptions;
910

1011
export type ScorecardProblem = NormalizedProblem & { scorecardLevel?: string };

packages/cli/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -811,6 +811,11 @@ yargs(hideBin(process.argv))
811811
describe: 'URL to the project scorecard configuration.',
812812
type: 'string',
813813
},
814+
output: {
815+
describe: 'Output file for JSON results.',
816+
type: 'string',
817+
alias: 'o',
818+
},
814819
});
815820
},
816821
async (argv) => {

0 commit comments

Comments
 (0)