|
| 1 | +#!/usr/bin/env node |
| 2 | + |
| 3 | +// PR-scoped ReSharper inspectcode gate. |
| 4 | +// |
| 5 | +// Runs inspectcode on the working tree, parses the SARIF report, filters to |
| 6 | +// findings located at lines the PR added or modified relative to a base ref, |
| 7 | +// and exits non-zero if any are found. This avoids requiring a baseline scrub |
| 8 | +// of pre-existing issues — only NEW findings at PR-touched lines block. |
| 9 | +// |
| 10 | +// Usage: |
| 11 | +// node scripts/audit-resharper-regression.js [--base origin/main] |
| 12 | +// [--sarif inspect-report/inspect.sarif] |
| 13 | +// [--skip-scan] |
| 14 | +// [--staged] |
| 15 | +// |
| 16 | +// --skip-scan reuses the SARIF from a prior `audit:resharper` run, useful in |
| 17 | +// CI where the full scan and the gate are split into separate steps so the |
| 18 | +// expensive scan output can be uploaded as an artifact. |
| 19 | +// |
| 20 | +// --staged filters findings to staged C# lines (`git diff --cached`) instead of |
| 21 | +// PR-diff lines. Pair with --skip-scan for fast iterative pre-commit checks |
| 22 | +// against an existing SARIF report. |
| 23 | + |
| 24 | +const fs = require("node:fs") |
| 25 | +const path = require("node:path") |
| 26 | +const { spawnSync } = require("node:child_process") |
| 27 | + |
| 28 | +const PROJECT_ROOT = path.join(__dirname, "..") |
| 29 | +const DEFAULT_SARIF = path.join(PROJECT_ROOT, "inspect-report", "inspect.sarif") |
| 30 | + |
| 31 | +// `git diff` output for a large solution can exceed Node's 1 MB default. |
| 32 | +const MAX_BUFFER_BYTES = 268_435_456 |
| 33 | + |
| 34 | +// Cap how many findings we print per rule before summarising the rest. |
| 35 | +const MAX_FINDINGS_PER_RULE = 5 |
| 36 | + |
| 37 | +function parseArgs(argv) { |
| 38 | + const args = { base: "origin/main", sarif: DEFAULT_SARIF, skipScan: false, staged: false } |
| 39 | + const remaining = [...argv] |
| 40 | + while (remaining.length > 0) { |
| 41 | + const flag = remaining.shift() |
| 42 | + if (flag === "--base") { |
| 43 | + args.base = remaining.shift() |
| 44 | + } else if (flag === "--sarif") { |
| 45 | + args.sarif = remaining.shift() |
| 46 | + } else if (flag === "--skip-scan") { |
| 47 | + args.skipScan = true |
| 48 | + } else if (flag === "--staged") { |
| 49 | + args.staged = true |
| 50 | + } else { |
| 51 | + console.error(`Unknown arg: ${flag}`) |
| 52 | + process.exit(2) |
| 53 | + } |
| 54 | + } |
| 55 | + return args |
| 56 | +} |
| 57 | + |
| 58 | +// Returns Map<repoRelativePath, Set<lineNumber>> of lines added/modified in |
| 59 | +// the diff selection: PR-mode uses `git diff base...HEAD`; staged-mode uses |
| 60 | +// `git diff --cached` (staged-vs-HEAD). |
| 61 | +function getChangedLines(diffSelector) { |
| 62 | + const result = spawnSync("git", ["diff", "--unified=0", ...diffSelector, "--", "*.cs"], { |
| 63 | + cwd: PROJECT_ROOT, |
| 64 | + encoding: "utf8", |
| 65 | + windowsHide: true, |
| 66 | + maxBuffer: MAX_BUFFER_BYTES, |
| 67 | + }) |
| 68 | + if (result.error || result.status !== 0) { |
| 69 | + const reason = result.error?.message ?? `exit ${result.status}` |
| 70 | + console.error(`❌ git diff failed: ${reason}`) |
| 71 | + process.exit(1) |
| 72 | + } |
| 73 | + |
| 74 | + const map = new Map() |
| 75 | + let currentFile = null |
| 76 | + for (const line of result.stdout.split(/\r?\n/)) { |
| 77 | + const fileMatch = line.match(/^\+\+\+ b\/(?<file>.+)$/) |
| 78 | + if (fileMatch?.groups) { |
| 79 | + currentFile = fileMatch.groups.file |
| 80 | + if (!map.has(currentFile)) { |
| 81 | + map.set(currentFile, new Set()) |
| 82 | + } |
| 83 | + } else if (currentFile) { |
| 84 | + const hunkMatch = line.match(/^@@ -\d+(?:,\d+)? \+(?<start>\d+)(?:,(?<count>\d+))? @@/) |
| 85 | + if (hunkMatch?.groups) { |
| 86 | + const start = Number(hunkMatch.groups.start) |
| 87 | + // A hunk count of 0 means lines were removed only at this position; nothing was added. |
| 88 | + const count = hunkMatch.groups.count === undefined ? 1 : Number(hunkMatch.groups.count) |
| 89 | + for (let offset = 0; offset < count; offset += 1) { |
| 90 | + map.get(currentFile).add(start + offset) |
| 91 | + } |
| 92 | + } |
| 93 | + } |
| 94 | + } |
| 95 | + // Drop entries for files where nothing was added (pure deletions). |
| 96 | + for (const [file, lines] of map) { |
| 97 | + if (lines.size === 0) { |
| 98 | + map.delete(file) |
| 99 | + } |
| 100 | + } |
| 101 | + return map |
| 102 | +} |
| 103 | + |
| 104 | +function runScan() { |
| 105 | + console.log("Running inspectcode (this takes a few minutes on a full solution)...") |
| 106 | + // Skip HTML; the gate only consumes SARIF, and inspectcode runs are expensive. |
| 107 | + const result = spawnSync(process.execPath, [path.join(__dirname, "audit-resharper.js"), "--format=sarif"], { |
| 108 | + cwd: PROJECT_ROOT, |
| 109 | + stdio: "inherit", |
| 110 | + windowsHide: true, |
| 111 | + }) |
| 112 | + if (result.error || result.status !== 0) { |
| 113 | + const reason = result.error?.message ?? `exit ${result.status}` |
| 114 | + console.error(`❌ inspectcode scan failed: ${reason}`) |
| 115 | + process.exit(1) |
| 116 | + } |
| 117 | +} |
| 118 | + |
| 119 | +// SARIF artifactLocation URIs use forward slashes and are repo-relative; git |
| 120 | +// diff emits forward slashes too, so a direct case-sensitive compare suffices |
| 121 | +// on Linux/macOS. On Windows match case-insensitively. |
| 122 | +function normalizeUri(uri) { |
| 123 | + let s = uri.replaceAll("\\", "/") |
| 124 | + if (s.startsWith("./")) { |
| 125 | + s = s.slice(2) |
| 126 | + } |
| 127 | + if (process.platform === "win32") { |
| 128 | + s = s.toLowerCase() |
| 129 | + } |
| 130 | + return s |
| 131 | +} |
| 132 | + |
| 133 | +function findRegressions(sarifPath, changedLines) { |
| 134 | + const sarif = JSON.parse(fs.readFileSync(sarifPath, "utf8")) |
| 135 | + const results = sarif.runs?.[0]?.results ?? [] |
| 136 | + |
| 137 | + const changedNorm = new Map() |
| 138 | + for (const [file, lines] of changedLines) { |
| 139 | + changedNorm.set(normalizeUri(file), lines) |
| 140 | + } |
| 141 | + |
| 142 | + const regressions = [] |
| 143 | + for (const r of results) { |
| 144 | + const ruleId = r.ruleId ?? "?" |
| 145 | + for (const loc of r.locations ?? []) { |
| 146 | + const uri = loc.physicalLocation?.artifactLocation?.uri |
| 147 | + const line = loc.physicalLocation?.region?.startLine |
| 148 | + if (uri && line) { |
| 149 | + const lines = changedNorm.get(normalizeUri(uri)) |
| 150 | + if (lines?.has(line)) { |
| 151 | + regressions.push({ |
| 152 | + file: uri, |
| 153 | + line, |
| 154 | + ruleId, |
| 155 | + message: r.message?.text ?? "", |
| 156 | + }) |
| 157 | + } |
| 158 | + } |
| 159 | + } |
| 160 | + } |
| 161 | + return regressions |
| 162 | +} |
| 163 | + |
| 164 | +const args = parseArgs(process.argv.slice(2)) |
| 165 | + |
| 166 | +if (!args.skipScan) { |
| 167 | + runScan() |
| 168 | +} |
| 169 | + |
| 170 | +if (!fs.existsSync(args.sarif)) { |
| 171 | + console.error(`❌ SARIF not found: ${args.sarif}`) |
| 172 | + process.exit(1) |
| 173 | +} |
| 174 | + |
| 175 | +const diffSelector = args.staged ? ["--cached"] : [`${args.base}...HEAD`] |
| 176 | +const diffLabel = args.staged ? "staged C# changes" : `base ${args.base}` |
| 177 | +const noChangesMsg = args.staged |
| 178 | + ? "✅ No staged C# changes; nothing to gate." |
| 179 | + : "✅ No C# changes in this PR; nothing to gate." |
| 180 | +const touchedLabel = args.staged ? "staged" : "PR-touched" |
| 181 | + |
| 182 | +const changed = getChangedLines(diffSelector) |
| 183 | +console.log(`Comparing against ${diffLabel}: ${changed.size} C# file(s) with added/modified lines`) |
| 184 | +if (changed.size === 0) { |
| 185 | + console.log(noChangesMsg) |
| 186 | + process.exit(0) |
| 187 | +} |
| 188 | + |
| 189 | +const regressions = findRegressions(args.sarif, changed) |
| 190 | +if (regressions.length === 0) { |
| 191 | + console.log(`✅ No new ReSharper warnings at ${touchedLabel} lines.`) |
| 192 | + process.exit(0) |
| 193 | +} |
| 194 | + |
| 195 | +console.error(`\n❌ ${regressions.length} new ReSharper warning(s) at ${touchedLabel} lines:\n`) |
| 196 | +const byRule = new Map() |
| 197 | +for (const r of regressions) { |
| 198 | + if (!byRule.has(r.ruleId)) { |
| 199 | + byRule.set(r.ruleId, []) |
| 200 | + } |
| 201 | + byRule.get(r.ruleId).push(r) |
| 202 | +} |
| 203 | +for (const [ruleId, items] of byRule) { |
| 204 | + console.error(` ${ruleId} (${items.length})`) |
| 205 | + for (const it of items.slice(0, MAX_FINDINGS_PER_RULE)) { |
| 206 | + console.error(` ${it.file}:${it.line} ${it.message}`) |
| 207 | + } |
| 208 | + if (items.length > MAX_FINDINGS_PER_RULE) { |
| 209 | + console.error(` ... and ${items.length - MAX_FINDINGS_PER_RULE} more`) |
| 210 | + } |
| 211 | +} |
| 212 | +process.exit(1) |
0 commit comments