diff --git a/actions/parse-ci-reports/README.md b/actions/parse-ci-reports/README.md index 9292a0b..f7bcc78 100644 --- a/actions/parse-ci-reports/README.md +++ b/actions/parse-ci-reports/README.md @@ -50,9 +50,28 @@ It supports multiple common report standards out of the box. - **ESLint JSON** - JavaScript/TypeScript linting - **CheckStyle XML** - Java and other language linting +- **SARIF** - Static analysis results in the SARIF 2.1.0 format - **Prettier Check Logs** - Text output captured from `prettier --check` - **Astro Check Logs** - Diagnostics emitted by `astro check` +### Expected Auto-Detection Paths + +When `report-paths` uses `auto:test`, `auto:coverage`, `auto:lint`, or `auto:all`, the action searches for these glob patterns: + +| **Report Type** | **Auto-Detected Paths** | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **JUnit XML** | `**/junit*.xml`, `**/test-results/**/*.xml`, `**/test-reports/**/*.xml`, `**/*test*.xml` | +| **TAP** | `**/*.tap` | +| **Cobertura XML** | `**/coverage/*-coverage.xml`, `**/coverage/*-cobertura.xml`, `**/coverage/coverage.xml`, `**/coverage/cobertura.xml` | +| **LCOV** | `**/coverage/lcov.info`, `**/lcov.info`, `**/coverage/*-lcov.info`, `**/*-lcov.info` | +| **ESLint JSON** | `**/eslint-report.json`, `**/eslint.json`, `**/*-eslint-report.json`, `**/*-eslint.json` | +| **CheckStyle XML** | `**/checkstyle-result.xml`, `**/checkstyle.xml`, `**/*-checkstyle-result.xml`, `**/*-checkstyle.xml` | +| **SARIF** | `**/*.sarif`, `**/*.sarif.json`, `**/sarif-report.json`, `**/*-sarif-report.json` | +| **Prettier Check Logs** | `**/prettier-check.log`, `**/prettier-check.txt`, `**/prettier-report.log`, `**/prettier-report.txt`, `**/*-prettier-check.log`, `**/*-prettier-check.txt`, `**/*-prettier-report.log`, `**/*-prettier-report.txt` | +| **Astro Check Logs** | `**/astro-check.log`, `**/astro-check.txt`, `**/astro-check-report.log`, `**/astro-check-report.txt`, `**/*-astro-check.log`, `**/*-astro-check.txt`, `**/*-astro-check-report.log`, `**/*-astro-check-report.txt` | + +If your reports are written elsewhere, pass explicit paths or glob patterns instead of relying on `auto:*` detection. + ## Usage @@ -62,7 +81,7 @@ It supports multiple common report standards out of the box. with: # Paths to report files (glob patterns supported, one per line or comma-separated). # Set to `auto:test`, `auto:coverage`, `auto:lint`, or `auto:all` for automatic detection. - # Examples: `**/junit.xml`, `coverage/lcov.info`, `eslint-report.json`, `auto:all`, `auto:test,coverage/lcov.info`, `auto:test,auto:coverage` + # Examples: `**/junit.xml`, `coverage/lcov.info`, `eslint-report.json`, `reports/results.sarif`, `auto:all`, `auto:test,coverage/lcov.info`, `auto:test,auto:coverage` # # Default: `auto:all` report-paths: auto:all @@ -110,26 +129,26 @@ It supports multiple common report standards out of the box. ## Inputs -| **Input** | **Description** | **Required** | **Default** | -| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------- | -| **`report-paths`** | Paths to report files (glob patterns supported, one per line or comma-separated). | **false** | `auto:all` | -| | Set to `auto:test`, `auto:coverage`, `auto:lint`, or `auto:all` for automatic detection. | | | -| | Examples: `**/junit.xml`, `coverage/lcov.info`, `eslint-report.json`, `auto:all`, `auto:test,coverage/lcov.info`, `auto:test,auto:coverage` | | | -| **`report-name`** | Name to display in the summary (e.g., `Test Results`, `Coverage Report`). | **false** | `Report Summary` | -| **`include-passed`** | Whether to include passed tests in the summary. | **false** | `false` | -| **`output-format`** | Output format: comma-separated list of `summary`, `markdown`, `annotations`, or `all` for everything. | **false** | `all` | -| **`fail-on-error`** | Whether to fail the action if any test failures are detected. | **false** | `false` | -| **`path-mapping`** | Path mapping(s) to rewrite file paths in reports (format: "from_path:to_path"). | **false** | - | -| | Useful when tests/lints run in a different directory or container. | | | -| | Multiple mappings can be provided separated by newlines or commas. | | | -| | Examples: | | | -| | - Single mapping: "/app/src:./src" | | | -| | - Multiple mappings: "/app/src:./src,/app/tests:./tests" | | | -| | - Multi-line: \| | | | -| | /app/src:./src | | | -| | /app/tests:./tests | | | -| **`working-directory`** | Working directory where the action should operate. | **false** | `.` | -| | Can be absolute or relative to the repository root. | | | +| **Input** | **Description** | **Required** | **Default** | +| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ---------------- | +| **`report-paths`** | Paths to report files (glob patterns supported, one per line or comma-separated). | **false** | `auto:all` | +| | Set to `auto:test`, `auto:coverage`, `auto:lint`, or `auto:all` for automatic detection. | | | +| | Examples: `**/junit.xml`, `coverage/lcov.info`, `eslint-report.json`, `reports/results.sarif`, `auto:all`, `auto:test,coverage/lcov.info`, `auto:test,auto:coverage` | | | +| **`report-name`** | Name to display in the summary (e.g., `Test Results`, `Coverage Report`). | **false** | `Report Summary` | +| **`include-passed`** | Whether to include passed tests in the summary. | **false** | `false` | +| **`output-format`** | Output format: comma-separated list of `summary`, `markdown`, `annotations`, or `all` for everything. | **false** | `all` | +| **`fail-on-error`** | Whether to fail the action if any test failures are detected. | **false** | `false` | +| **`path-mapping`** | Path mapping(s) to rewrite file paths in reports (format: "from_path:to_path"). | **false** | - | +| | Useful when tests/lints run in a different directory or container. | | | +| | Multiple mappings can be provided separated by newlines or commas. | | | +| | Examples: | | | +| | - Single mapping: "/app/src:./src" | | | +| | - Multiple mappings: "/app/src:./src,/app/tests:./tests" | | | +| | - Multi-line: \| | | | +| | /app/src:./src | | | +| | /app/tests:./tests | | | +| **`working-directory`** | Working directory where the action should operate. | **false** | `.` | +| | Can be absolute or relative to the repository root. | | | @@ -178,7 +197,7 @@ Auto-detection modes: - `auto:coverage` - Finds LCOV and Cobertura coverage files -- `auto:lint` - Finds ESLint JSON and CheckStyle XML files +- `auto:lint` - Finds ESLint JSON, CheckStyle XML, SARIF files, Prettier check logs, and Astro check logs - `auto:all` - Finds all supported report types @@ -253,6 +272,22 @@ linting tools: output-format: "summary,annotations" ``` +### SARIF Static Analysis + +Parse SARIF output from tools such as CodeQL or other static analyzers: + +```yaml +- name: Run static analysis + run: codeql database analyze db javascript-security-extended --format=sarif-latest --output=reports/results.sarif + +- name: Parse SARIF report + uses: hoverkraft-tech/ci-github-common/actions/parse-ci-reports@66578f5b9aec4ac5558b5dad750c4c74dfcb65c5 # 0.35.5 + with: + report-paths: "reports/results.sarif" + report-name: "Static Analysis" + output-format: "summary,annotations" +``` + ### Fail on Test Failures ```yaml @@ -464,6 +499,7 @@ src/ │ ├── LCOVParser.js │ ├── ESLintParser.js │ ├── CheckStyleParser.js +│ ├── SarifParser.js │ └── ParserFactory.js # Factory pattern for parser selection ├── formatters/ # Output formatters │ ├── SummaryFormatter.js diff --git a/actions/parse-ci-reports/action.yml b/actions/parse-ci-reports/action.yml index 634af14..98fcc37 100644 --- a/actions/parse-ci-reports/action.yml +++ b/actions/parse-ci-reports/action.yml @@ -18,7 +18,7 @@ inputs: description: | Paths to report files (glob patterns supported, one per line or comma-separated). Set to `auto:test`, `auto:coverage`, `auto:lint`, or `auto:all` for automatic detection. - Examples: `**/junit.xml`, `coverage/lcov.info`, `eslint-report.json`, `auto:all`, `auto:test,coverage/lcov.info`, `auto:test,auto:coverage` + Examples: `**/junit.xml`, `coverage/lcov.info`, `eslint-report.json`, `reports/results.sarif`, `auto:all`, `auto:test,coverage/lcov.info`, `auto:test,auto:coverage` required: false default: "auto:all" report-name: diff --git a/actions/parse-ci-reports/package.json b/actions/parse-ci-reports/package.json index 158f8db..1aa37a1 100644 --- a/actions/parse-ci-reports/package.json +++ b/actions/parse-ci-reports/package.json @@ -18,7 +18,8 @@ "lcov", "cobertura", "eslint", - "checkstyle" + "checkstyle", + "sarif" ], "author": "hoverkraft", "license": "MIT", diff --git a/actions/parse-ci-reports/src/ReportPathResolver.test.js b/actions/parse-ci-reports/src/ReportPathResolver.test.js index 113c17d..b85e394 100644 --- a/actions/parse-ci-reports/src/ReportPathResolver.test.js +++ b/actions/parse-ci-reports/src/ReportPathResolver.test.js @@ -99,9 +99,12 @@ describe("ReportPathResolver", () => { const coveragePatterns = [ "**/coverage/lcov.info", "**/lcov.info", - "**/coverage/cobertura-coverage.xml", - "**/coverage.xml", - "**/cobertura.xml", + "**/coverage/*-lcov.info", + "**/*-lcov.info", + "**/coverage/coverage.xml", + "**/coverage/*-coverage.xml", + "**/coverage/cobertura.xml", + "**/coverage/*-cobertura.xml", ]; for (const pattern of coveragePatterns) { assert.ok( @@ -119,8 +122,8 @@ describe("ReportPathResolver", () => { ); } - // Verify total count matches expected (5 test + 5 coverage) - assert.strictEqual(patterns.length, 10); + // Verify total count matches expected (5 test + 8 coverage) + assert.strictEqual(patterns.length, 13); }); it("deduplicates overlapping auto modes", () => { @@ -133,16 +136,32 @@ describe("ReportPathResolver", () => { const lintPatterns = [ "**/eslint-report.json", "**/eslint.json", + "**/*-eslint-report.json", + "**/*-eslint.json", "**/checkstyle-result.xml", "**/checkstyle.xml", + "**/*-checkstyle-result.xml", + "**/*-checkstyle.xml", + "**/*.sarif", + "**/*.sarif.json", + "**/sarif-report.json", + "**/*-sarif-report.json", "**/prettier-check.log", "**/prettier-check.txt", "**/prettier-report.log", "**/prettier-report.txt", + "**/*-prettier-check.log", + "**/*-prettier-check.txt", + "**/*-prettier-report.log", + "**/*-prettier-report.txt", "**/astro-check.log", "**/astro-check.txt", "**/astro-check-report.log", "**/astro-check-report.txt", + "**/*-astro-check.log", + "**/*-astro-check.txt", + "**/*-astro-check-report.log", + "**/*-astro-check-report.txt", ]; for (const pattern of lintPatterns) { assert.ok(patterns.includes(pattern), `Missing lint pattern: ${pattern}`); @@ -164,9 +183,12 @@ describe("ReportPathResolver", () => { const coveragePatterns = [ "**/coverage/lcov.info", "**/lcov.info", - "**/coverage/cobertura-coverage.xml", - "**/coverage.xml", - "**/cobertura.xml", + "**/coverage/*-lcov.info", + "**/*-lcov.info", + "**/coverage/coverage.xml", + "**/coverage/*-coverage.xml", + "**/coverage/cobertura.xml", + "**/coverage/*-cobertura.xml", ]; for (const pattern of coveragePatterns) { assert.ok( @@ -175,8 +197,8 @@ describe("ReportPathResolver", () => { ); } - // Verify deduplication - total should be 12 lint + 5 test + 5 coverage = 22 - assert.strictEqual(patterns.length, 22); + // Verify deduplication - total should be 28 lint + 5 test + 8 coverage = 41 + assert.strictEqual(patterns.length, 41); }); it("gets patterns from parsers via getAutoPatterns", () => { @@ -196,13 +218,22 @@ describe("ReportPathResolver", () => { // Verify coverage patterns come from LCOVParser and CoberturaParser assert.ok(autoPatterns.coverage.includes("**/coverage/lcov.info")); - assert.ok(autoPatterns.coverage.includes("**/cobertura.xml")); + assert.ok(autoPatterns.coverage.includes("**/coverage/cobertura.xml")); + assert.ok(autoPatterns.coverage.includes("**/*-lcov.info")); + assert.ok(autoPatterns.coverage.includes("**/coverage/*-coverage.xml")); + assert.ok(autoPatterns.coverage.includes("**/coverage/*-cobertura.xml")); - // Verify lint patterns come from ESLintParser, CheckStyleParser, PrettierParser, AstroCheckParser + // Verify lint patterns come from ESLintParser, CheckStyleParser, SarifParser, PrettierParser, AstroCheckParser assert.ok(autoPatterns.lint.includes("**/eslint-report.json")); assert.ok(autoPatterns.lint.includes("**/checkstyle.xml")); + assert.ok(autoPatterns.lint.includes("**/*.sarif")); assert.ok(autoPatterns.lint.includes("**/prettier-check.log")); assert.ok(autoPatterns.lint.includes("**/astro-check.log")); + assert.ok(autoPatterns.lint.includes("**/*-eslint.json")); + assert.ok(autoPatterns.lint.includes("**/*-checkstyle.xml")); + assert.ok(autoPatterns.lint.includes("**/*-sarif-report.json")); + assert.ok(autoPatterns.lint.includes("**/*-prettier-check.log")); + assert.ok(autoPatterns.lint.includes("**/*-astro-check.log")); }); it("excludes node_modules files from glob patterns", async () => { diff --git a/actions/parse-ci-reports/src/parsers/AstroCheckParser.js b/actions/parse-ci-reports/src/parsers/AstroCheckParser.js index 3491caa..41af0c5 100644 --- a/actions/parse-ci-reports/src/parsers/AstroCheckParser.js +++ b/actions/parse-ci-reports/src/parsers/AstroCheckParser.js @@ -34,12 +34,15 @@ export class AstroCheckParser extends BaseParser { } getAutoPatterns() { - return [ - "**/astro-check.log", - "**/astro-check.txt", - "**/astro-check-report.log", - "**/astro-check-report.txt", - ]; + return this.buildBasenamePatterns( + [ + "astro-check.log", + "astro-check.txt", + "astro-check-report.log", + "astro-check-report.txt", + ], + { includePrefixed: true }, + ); } parse(content) { diff --git a/actions/parse-ci-reports/src/parsers/BaseParser.js b/actions/parse-ci-reports/src/parsers/BaseParser.js index 102f261..a4b9d29 100644 --- a/actions/parse-ci-reports/src/parsers/BaseParser.js +++ b/actions/parse-ci-reports/src/parsers/BaseParser.js @@ -12,6 +12,60 @@ export const ReportCategory = { * Follows the Strategy pattern for different report formats */ export class BaseParser { + /** + * Build glob patterns for file basenames matched anywhere in the workspace. + * @param {string[]} baseNames - File basenames to match + * @param {Object} [options] - Pattern options + * @param {boolean} [options.includePrefixed=false] - Also match *-prefixed variants + * @returns {string[]} Array of glob patterns + */ + buildBasenamePatterns(baseNames, { includePrefixed = false } = {}) { + const patterns = []; + + for (const baseName of baseNames) { + patterns.push(`**/${baseName}`); + if (includePrefixed) { + patterns.push(`**/*-${baseName}`); + } + } + + return patterns; + } + + /** + * Build glob patterns for filenames within specific directories. + * @param {string} baseName - File basename to match + * @param {string[]} directories - Directory segments relative to any workspace folder + * @param {Object} [options] - Pattern options + * @param {boolean} [options.includePrefixed=false] - Also match *-prefixed variants + * @returns {string[]} Array of glob patterns + */ + buildScopedBasenamePatterns( + baseName, + directories, + { includePrefixed = false } = {}, + ) { + const patterns = []; + + for (const directory of directories) { + patterns.push(`**/${directory}/${baseName}`); + if (includePrefixed) { + patterns.push(`**/${directory}/*-${baseName}`); + } + } + + return patterns; + } + + /** + * Build extension-based glob patterns. + * @param {string[]} extensions - Extensions without the leading *. + * @returns {string[]} Array of glob patterns + */ + buildExtensionPatterns(extensions) { + return extensions.map((extension) => `**/*.${extension}`); + } + /** * Parse a report file * @param {string} content - The file content diff --git a/actions/parse-ci-reports/src/parsers/CheckStyleParser.js b/actions/parse-ci-reports/src/parsers/CheckStyleParser.js index be9b2aa..c4a482b 100644 --- a/actions/parse-ci-reports/src/parsers/CheckStyleParser.js +++ b/actions/parse-ci-reports/src/parsers/CheckStyleParser.js @@ -33,7 +33,10 @@ export class CheckStyleParser extends BaseParser { } getAutoPatterns() { - return ["**/checkstyle-result.xml", "**/checkstyle.xml"]; + return this.buildBasenamePatterns( + ["checkstyle-result.xml", "checkstyle.xml"], + { includePrefixed: true }, + ); } parse(content) { diff --git a/actions/parse-ci-reports/src/parsers/CoberturaParser.js b/actions/parse-ci-reports/src/parsers/CoberturaParser.js index c003385..4ea9f7a 100644 --- a/actions/parse-ci-reports/src/parsers/CoberturaParser.js +++ b/actions/parse-ci-reports/src/parsers/CoberturaParser.js @@ -34,9 +34,12 @@ export class CoberturaParser extends BaseParser { getAutoPatterns() { return [ - "**/coverage/cobertura-coverage.xml", - "**/coverage.xml", - "**/cobertura.xml", + ...this.buildScopedBasenamePatterns("coverage.xml", ["coverage"], { + includePrefixed: true, + }), + ...this.buildScopedBasenamePatterns("cobertura.xml", ["coverage"], { + includePrefixed: true, + }), ]; } diff --git a/actions/parse-ci-reports/src/parsers/ESLintParser.js b/actions/parse-ci-reports/src/parsers/ESLintParser.js index 71153d5..ed32ad1 100644 --- a/actions/parse-ci-reports/src/parsers/ESLintParser.js +++ b/actions/parse-ci-reports/src/parsers/ESLintParser.js @@ -30,7 +30,9 @@ export class ESLintParser extends BaseParser { } getAutoPatterns() { - return ["**/eslint-report.json", "**/eslint.json"]; + return this.buildBasenamePatterns(["eslint-report.json", "eslint.json"], { + includePrefixed: true, + }); } parse(content) { diff --git a/actions/parse-ci-reports/src/parsers/LCOVParser.js b/actions/parse-ci-reports/src/parsers/LCOVParser.js index 29ec1fe..98022ee 100644 --- a/actions/parse-ci-reports/src/parsers/LCOVParser.js +++ b/actions/parse-ci-reports/src/parsers/LCOVParser.js @@ -23,7 +23,14 @@ export class LCOVParser extends BaseParser { } getAutoPatterns() { - return ["**/coverage/lcov.info", "**/lcov.info"]; + return [ + ...this.buildScopedBasenamePatterns("lcov.info", ["coverage"], { + includePrefixed: true, + }), + ...this.buildBasenamePatterns(["lcov.info"], { + includePrefixed: true, + }), + ]; } parse(content) { diff --git a/actions/parse-ci-reports/src/parsers/ParserFactory.js b/actions/parse-ci-reports/src/parsers/ParserFactory.js index e584e63..b39f659 100644 --- a/actions/parse-ci-reports/src/parsers/ParserFactory.js +++ b/actions/parse-ci-reports/src/parsers/ParserFactory.js @@ -6,6 +6,7 @@ import { ESLintParser } from "./ESLintParser.js"; import { CheckStyleParser } from "./CheckStyleParser.js"; import { PrettierParser } from "./PrettierParser.js"; import { AstroCheckParser } from "./AstroCheckParser.js"; +import { SarifParser } from "./SarifParser.js"; import { ReportCategory } from "./BaseParser.js"; /** @@ -25,6 +26,7 @@ export class ParserFactory { new CheckStyleParser(), new PrettierParser(), new AstroCheckParser(), + new SarifParser(), ]; // Sort parsers by priority (highest first) diff --git a/actions/parse-ci-reports/src/parsers/ParserFactory.test.js b/actions/parse-ci-reports/src/parsers/ParserFactory.test.js index 5688dbc..60564ae 100644 --- a/actions/parse-ci-reports/src/parsers/ParserFactory.test.js +++ b/actions/parse-ci-reports/src/parsers/ParserFactory.test.js @@ -27,11 +27,14 @@ describe("ParserFactory", () => { // LCOVParser patterns assert.ok(coveragePatterns.includes("**/coverage/lcov.info")); assert.ok(coveragePatterns.includes("**/lcov.info")); + assert.ok(coveragePatterns.includes("**/coverage/*-lcov.info")); + assert.ok(coveragePatterns.includes("**/*-lcov.info")); // CoberturaParser patterns - assert.ok(coveragePatterns.includes("**/coverage/cobertura-coverage.xml")); - assert.ok(coveragePatterns.includes("**/coverage.xml")); - assert.ok(coveragePatterns.includes("**/cobertura.xml")); + assert.ok(coveragePatterns.includes("**/coverage/coverage.xml")); + assert.ok(coveragePatterns.includes("**/coverage/*-coverage.xml")); + assert.ok(coveragePatterns.includes("**/coverage/cobertura.xml")); + assert.ok(coveragePatterns.includes("**/coverage/*-cobertura.xml")); }); it("returns lint patterns from lint parsers", () => { @@ -40,22 +43,40 @@ describe("ParserFactory", () => { // ESLintParser patterns assert.ok(lintPatterns.includes("**/eslint-report.json")); assert.ok(lintPatterns.includes("**/eslint.json")); + assert.ok(lintPatterns.includes("**/*-eslint-report.json")); + assert.ok(lintPatterns.includes("**/*-eslint.json")); // CheckStyleParser patterns assert.ok(lintPatterns.includes("**/checkstyle-result.xml")); assert.ok(lintPatterns.includes("**/checkstyle.xml")); + assert.ok(lintPatterns.includes("**/*-checkstyle-result.xml")); + assert.ok(lintPatterns.includes("**/*-checkstyle.xml")); // PrettierParser patterns assert.ok(lintPatterns.includes("**/prettier-check.log")); assert.ok(lintPatterns.includes("**/prettier-check.txt")); assert.ok(lintPatterns.includes("**/prettier-report.log")); assert.ok(lintPatterns.includes("**/prettier-report.txt")); + assert.ok(lintPatterns.includes("**/*-prettier-check.log")); + assert.ok(lintPatterns.includes("**/*-prettier-check.txt")); + assert.ok(lintPatterns.includes("**/*-prettier-report.log")); + assert.ok(lintPatterns.includes("**/*-prettier-report.txt")); // AstroCheckParser patterns assert.ok(lintPatterns.includes("**/astro-check.log")); assert.ok(lintPatterns.includes("**/astro-check.txt")); assert.ok(lintPatterns.includes("**/astro-check-report.log")); assert.ok(lintPatterns.includes("**/astro-check-report.txt")); + assert.ok(lintPatterns.includes("**/*-astro-check.log")); + assert.ok(lintPatterns.includes("**/*-astro-check.txt")); + assert.ok(lintPatterns.includes("**/*-astro-check-report.log")); + assert.ok(lintPatterns.includes("**/*-astro-check-report.txt")); + + // SarifParser patterns + assert.ok(lintPatterns.includes("**/*.sarif")); + assert.ok(lintPatterns.includes("**/*.sarif.json")); + assert.ok(lintPatterns.includes("**/sarif-report.json")); + assert.ok(lintPatterns.includes("**/*-sarif-report.json")); }); it("returns all patterns across all categories", () => { @@ -67,8 +88,8 @@ describe("ParserFactory", () => { assert.ok(allPatterns.includes("**/eslint-report.json")); // lint // Should have the total expected count - // Test: 5, Coverage: 5, Lint: 12 - assert.strictEqual(allPatterns.length, 22); + // Test: 5, Coverage: 8, Lint: 28 + assert.strictEqual(allPatterns.length, 41); }); it("returns patterns grouped by category", () => { diff --git a/actions/parse-ci-reports/src/parsers/PrettierParser.js b/actions/parse-ci-reports/src/parsers/PrettierParser.js index 5dd3241..a7a9c6a 100644 --- a/actions/parse-ci-reports/src/parsers/PrettierParser.js +++ b/actions/parse-ci-reports/src/parsers/PrettierParser.js @@ -42,12 +42,15 @@ export class PrettierParser extends BaseParser { } getAutoPatterns() { - return [ - "**/prettier-check.log", - "**/prettier-check.txt", - "**/prettier-report.log", - "**/prettier-report.txt", - ]; + return this.buildBasenamePatterns( + [ + "prettier-check.log", + "prettier-check.txt", + "prettier-report.log", + "prettier-report.txt", + ], + { includePrefixed: true }, + ); } parse(content) { diff --git a/actions/parse-ci-reports/src/parsers/SarifParser.js b/actions/parse-ci-reports/src/parsers/SarifParser.js new file mode 100644 index 0000000..8395f1f --- /dev/null +++ b/actions/parse-ci-reports/src/parsers/SarifParser.js @@ -0,0 +1,149 @@ +import { fileURLToPath } from "node:url"; +import { BaseParser, ReportCategory } from "./BaseParser.js"; +import { ReportData, LintIssue } from "../models/ReportData.js"; + +/** + * Parser for SARIF 2.1.0 static analysis results + */ +export class SarifParser extends BaseParser { + canParse(_filePath, content) { + if (!content) { + return false; + } + + try { + const sarif = JSON.parse(content); + return ( + typeof sarif.version === "string" && + sarif.version.startsWith("2.") && + Array.isArray(sarif.runs) + ); + } catch { + return false; + } + } + + getPriority() { + return 9; + } + + getCategory() { + return ReportCategory.LINT; + } + + getAutoPatterns() { + return [ + ...this.buildExtensionPatterns(["sarif", "sarif.json"]), + ...this.buildBasenamePatterns(["sarif-report.json"], { + includePrefixed: true, + }), + ]; + } + + parse(content) { + const reportData = new ReportData(); + reportData.reportType = "lint"; + + try { + const sarif = JSON.parse(content); + const runs = Array.isArray(sarif.runs) ? sarif.runs : []; + + for (const run of runs) { + this._parseRun(run, reportData); + } + } catch (error) { + throw new Error(`Failed to parse SARIF JSON: ${error.message}`); + } + + return reportData; + } + + _parseRun(run, reportData) { + const results = Array.isArray(run?.results) ? run.results : []; + if (results.length === 0) { + return; + } + + const rules = Array.isArray(run?.tool?.driver?.rules) + ? run.tool.driver.rules + : []; + const toolName = run?.tool?.driver?.name || "sarif"; + + for (const result of results) { + const rule = this._resolveRule(result, rules); + const issue = this._createIssue(result, rule, toolName); + + if (issue) { + reportData.addLintIssue(issue); + } + } + } + + _resolveRule(result, rules) { + if (typeof result?.ruleIndex === "number" && rules[result.ruleIndex]) { + return rules[result.ruleIndex]; + } + + if (!result?.ruleId) { + return null; + } + + return rules.find((rule) => rule?.id === result.ruleId) || null; + } + + _createIssue(result, rule, toolName) { + const location = result?.locations?.[0]?.physicalLocation; + const region = location?.region || {}; + + return new LintIssue({ + file: this._extractFile(location?.artifactLocation?.uri), + line: Number.parseInt(region.startLine || 0, 10), + column: Number.parseInt(region.startColumn || 0, 10), + severity: this._mapSeverity( + result?.level || rule?.defaultConfiguration?.level, + ), + rule: result?.ruleId || rule?.id || "unknown", + message: this._resolveMessage(result, rule), + source: toolName, + }); + } + + _extractFile(uri) { + if (!uri) { + return "unknown"; + } + + if (uri.startsWith("file://")) { + return fileURLToPath(uri); + } + + try { + return decodeURIComponent(uri); + } catch { + return uri; + } + } + + _resolveMessage(result, rule) { + return ( + result?.message?.text || + result?.message?.markdown || + rule?.shortDescription?.text || + "SARIF issue" + ); + } + + _mapSeverity(level) { + switch (String(level || "warning").toLowerCase()) { + case "error": + return "error"; + case "warning": + return "warning"; + case "note": + case "none": + return "info"; + default: + return "warning"; + } + } +} diff --git a/actions/parse-ci-reports/src/parsers/SarifParser.test.js b/actions/parse-ci-reports/src/parsers/SarifParser.test.js new file mode 100644 index 0000000..34980b3 --- /dev/null +++ b/actions/parse-ci-reports/src/parsers/SarifParser.test.js @@ -0,0 +1,139 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; +import { SarifParser } from "./SarifParser.js"; + +const SAMPLE_SARIF = JSON.stringify({ + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "CodeQL", + rules: [ + { + id: "js/missing-await", + shortDescription: { text: "Missing await" }, + defaultConfiguration: { level: "warning" }, + }, + ], + }, + }, + results: [ + { + ruleId: "js/missing-await", + message: { text: "Promise returned from call is not awaited." }, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/app.js" }, + region: { startLine: 12, startColumn: 5 }, + }, + }, + ], + }, + { + ruleId: "js/unsafe-eval", + level: "error", + message: { text: "Untrusted data is passed to eval." }, + locations: [ + { + physicalLocation: { + artifactLocation: { + uri: "file:///workspace/src/index.js", + }, + region: { startLine: 3, startColumn: 1 }, + }, + }, + ], + }, + ], + }, + ], +}); + +describe("SarifParser", () => { + it("identifies SARIF reports", () => { + const parser = new SarifParser(); + + assert.ok(parser.canParse("results.sarif", SAMPLE_SARIF)); + assert.ok(parser.canParse("reports/analysis.sarif.json", SAMPLE_SARIF)); + assert.ok(!parser.canParse("eslint.json", "[]")); + }); + + it("parses SARIF results into lint issues", () => { + const parser = new SarifParser(); + + const reportData = parser.parse(SAMPLE_SARIF, "results.sarif"); + assert.strictEqual(reportData.lintIssues.length, 2); + + const [warningIssue, errorIssue] = reportData.lintIssues; + + assert.strictEqual(warningIssue.file, "src/app.js"); + assert.strictEqual(warningIssue.line, 12); + assert.strictEqual(warningIssue.column, 5); + assert.strictEqual(warningIssue.severity, "warning"); + assert.strictEqual(warningIssue.rule, "js/missing-await"); + assert.strictEqual( + warningIssue.message, + "Promise returned from call is not awaited.", + ); + assert.strictEqual(warningIssue.source, "CodeQL"); + + assert.strictEqual(errorIssue.file, "/workspace/src/index.js"); + assert.strictEqual(errorIssue.line, 3); + assert.strictEqual(errorIssue.column, 1); + assert.strictEqual(errorIssue.severity, "error"); + assert.strictEqual(errorIssue.rule, "js/unsafe-eval"); + assert.strictEqual(errorIssue.message, "Untrusted data is passed to eval."); + }); + + it("falls back to rule metadata when result level or message is omitted", () => { + const parser = new SarifParser(); + const reportData = parser.parse( + JSON.stringify({ + version: "2.1.0", + runs: [ + { + tool: { + driver: { + name: "Semgrep", + rules: [ + { + id: "security.rule", + shortDescription: { text: "Fallback message" }, + defaultConfiguration: { level: "warning" }, + }, + ], + }, + }, + results: [ + { + ruleIndex: 0, + locations: [ + { + physicalLocation: { + artifactLocation: { uri: "src/feature.ts" }, + region: { startLine: 8 }, + }, + }, + ], + }, + ], + }, + ], + }), + "report.sarif", + ); + + assert.strictEqual(reportData.lintIssues.length, 1); + const [issue] = reportData.lintIssues; + + assert.strictEqual(issue.file, "src/feature.ts"); + assert.strictEqual(issue.line, 8); + assert.strictEqual(issue.column, 0); + assert.strictEqual(issue.severity, "warning"); + assert.strictEqual(issue.rule, "security.rule"); + assert.strictEqual(issue.message, "Fallback message"); + assert.strictEqual(issue.source, "Semgrep"); + }); +});