Skip to content

Commit 00d2eaa

Browse files
authored
Merge pull request #175 from ucdavis/tools/codeql-roslyn-sonar-resharper
PR 2: chore(quality): add C# analyzers + ReSharper PR gate
2 parents d93562e + ccde16a commit 00d2eaa

9 files changed

Lines changed: 421 additions & 5 deletions

File tree

.config/dotnet-tools.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@
1313
"commands": [
1414
"libman"
1515
]
16+
},
17+
"jetbrains.resharper.globaltools": {
18+
"version": "2026.1.1",
19+
"commands": [
20+
"jb"
21+
]
1622
}
1723
}
1824
}

.editorconfig

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,25 @@ dotnet_diagnostic.IDE0005.severity = error
2323
dotnet_diagnostic.IDE0059.severity = warning
2424
# CS8601: Possible null reference assignment.
2525
dotnet_diagnostic.CS8601.severity = silent
26+
27+
# Maintainability rules (off by default in NetAnalyzers; enable explicitly)
28+
# CA1502: Avoid excessive cyclomatic complexity (default threshold 25)
29+
dotnet_diagnostic.CA1502.severity = warning
30+
# CA1505: Avoid unmaintainable code (maintainability index < 10)
31+
dotnet_diagnostic.CA1505.severity = warning
32+
# CA1501: Avoid excessive inheritance (default 5 levels)
33+
dotnet_diagnostic.CA1501.severity = warning
34+
# CA1507: Use nameof in place of string literal
35+
dotnet_diagnostic.CA1507.severity = warning
36+
# CA1508: Avoid dead conditional code (dataflow analysis - on trial; investigate FP rate)
37+
dotnet_diagnostic.CA1508.severity = warning
38+
# CA1510-CA1513: Use throw-helper APIs (.NET 6+ shorthands for argument validation)
39+
dotnet_diagnostic.CA1510.severity = warning
40+
dotnet_diagnostic.CA1511.severity = warning
41+
dotnet_diagnostic.CA1512.severity = warning
42+
dotnet_diagnostic.CA1513.severity = warning
43+
# CA1514: Avoid redundant length argument (Substring/Span slicing)
44+
dotnet_diagnostic.CA1514.severity = warning
2645
csharp_indent_labels = one_less_than_current
2746
csharp_using_directive_placement = outside_namespace:silent
2847
csharp_prefer_simple_using_statement = true:suggestion

.github/workflows/code-quality.yml

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ on:
77
# Detects whether a PR *adds* new dead code or duplication compared to main.
88
# - fallow covers VueApp (dead code, unused exports, complexity, duplication)
99
# - jscpd covers C# (web/Areas/**/*.cs) since fallow is JS/TS-only
10+
# - resharper-pr-gate covers C# inspections (dead-conditional, NRT-contract, and
11+
# redundancy findings the Roslyn build doesn't catch); PR-scoped so pre-existing
12+
# issues are not blocking — only NEW findings at lines this PR added/modified fail.
1013
# Passes if counts are equal to or lower than main.
1114

1215
jobs:
@@ -110,3 +113,44 @@ jobs:
110113
111114
- name: Check jscpd regression on PR
112115
run: node scripts/audit-jscpd-regression.js --check .jscpd-baseline-vue.json VueApp/src
116+
117+
resharper-pr-gate:
118+
name: ReSharper PR-scoped gate (C#)
119+
runs-on: ubuntu-latest
120+
steps:
121+
- name: Checkout PR head
122+
uses: actions/checkout@v6
123+
with:
124+
fetch-depth: 0
125+
126+
- uses: actions/setup-node@v6
127+
with:
128+
node-version: 24
129+
cache: npm
130+
cache-dependency-path: package-lock.json
131+
132+
- uses: actions/setup-dotnet@v4
133+
with:
134+
global-json-file: global.json
135+
136+
- name: Install dependencies (root)
137+
run: npm ci
138+
139+
- name: Restore dotnet local tools (jb / dotnet-ef / libman)
140+
run: dotnet tool restore
141+
142+
- name: Restore NuGet
143+
run: dotnet restore Viper.sln
144+
145+
- name: Run inspectcode + PR-diff gate
146+
# The script runs the full inspectcode scan, parses the SARIF, and only
147+
# fails on findings located at lines this PR added/modified vs base ref.
148+
run: node scripts/audit-resharper-regression.js --base origin/${{ github.base_ref }}
149+
150+
- name: Upload SARIF report
151+
if: always()
152+
uses: actions/upload-artifact@v4
153+
with:
154+
name: resharper-sarif
155+
path: inspect-report/inspect.sarif
156+
retention-days: 3

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -492,13 +492,15 @@ awscredentials.xml
492492
# Precommit build artifacts (isolated from dev server)
493493
.artifacts-precommit/
494494
.artifacts-lint/
495+
.artifacts-resharper/
495496

496497
# Effort migration script outputs
497498
web/Areas/Effort/Scripts/AnalysisOutput/
498499
web/Areas/Effort/Scripts/RemediationOutput/
499500
web/Areas/Effort/Scripts/Effort_Database_Schema_And_Data_LEGACY.txt
500501

501-
# Code-quality tool outputs (fallow cache + jscpd reports)
502+
# Code-quality tool outputs (fallow cache + jscpd + ReSharper reports)
502503
.fallow/
503504
VueApp/.fallow/
504505
jscpd-report/
506+
inspect-report/

README.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,24 @@ The project includes VS Code launch configurations for debugging both frontend a
179179
- `npm run lint:staged` - Lint only git-staged files
180180
- `npm run precommit` - Run full pre-commit checks manually (lint, test, build verify)
181181

182+
### Auditing (regression detection)
183+
184+
Heavier whole-project scanners that surface dead code, duplication, and C# inspection findings that the per-file linter doesn't catch. Outputs land in gitignored report directories (`.fallow/`, `jscpd-report/`, `inspect-report/`).
185+
186+
**Whole-project scans** — run locally for human review:
187+
188+
- `npm run audit` - Run all whole-project audits (fallow + jscpd + ReSharper); takes several minutes due to the ReSharper scan
189+
- `npm run audit:fallow` - Vue/TS dead code, unused exports, complexity, duplication (`VueApp/`)
190+
- `npm run audit:dupes` - jscpd duplicate-code report across `VueApp/src/` and `web/Areas/`
191+
- `npm run audit:resharper` - ReSharper `inspectcode` whole-solution scan (writes SARIF + HTML to `inspect-report/`); takes several minutes
192+
193+
**Diff-scoped gates** — fail only on *new* findings at touched lines, so pre-existing issues never block:
194+
195+
- `npm run audit:resharper:pr` - C# inspection gate vs `origin/main`. Same gate the `Code Quality` GitHub Actions workflow runs on PRs.
196+
- `npm run audit:resharper-staged` - C# inspection gate vs the git index (`git diff --cached`). Useful as a manual pre-commit check. For fast iteration after one full scan, append `-- --skip-scan` to reuse the existing SARIF: `npm run audit:resharper-staged -- --skip-scan`.
197+
198+
**CI integration**`.github/workflows/code-quality.yml` runs four gates on every PR to `main`: `fallow-regression` (Vue), `jscpd-regression-csharp`, `jscpd-regression-vue`, and `resharper-pr-gate` (C# inspections). All four are diff-scoped so existing technical debt doesn't block merges.
199+
182200
### Build Cache
183201

184202
The linter, test, and build verification scripts use caching to avoid redundant rebuilds. If you encounter stale cache issues (e.g., linter showing warnings for already-fixed code), clear the cache:

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@
2222
"audit": "node scripts/audit.js",
2323
"audit:fallow": "node scripts/audit-fallow.js",
2424
"audit:dupes": "node scripts/audit-jscpd.js",
25+
"audit:resharper": "node scripts/audit-resharper.js",
26+
"audit:resharper:pr": "node scripts/audit-resharper-regression.js",
27+
"audit:resharper-staged": "node scripts/audit-resharper-regression.js --staged",
2528
"mailpit:start": "node --env-file-if-exists=.env.local scripts/manage-mailpit.js start",
2629
"mailpit:status": "node --env-file-if-exists=.env.local scripts/manage-mailpit.js status",
2730
"mailpit:stop": "node --env-file-if-exists=.env.local scripts/manage-mailpit.js stop",
Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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

Comments
 (0)