Static Analysis
Week 11 · Adding analyzers to Project Pulse, a codebase that never had them
Week 11 · Adding analyzers to Project Pulse, a codebase that never had them
public Result addActivityComment(...) {
String comment = commentMap.get("comment");
Activity updatedActivity = this.activityService.addActivityComment(activityId, comment);
ActivityDto updatedActivityDto = this.activityToActivityDtoConverter.convert(updatedActivity);
return new Result(true, StatusCode.SUCCESS, "Add comment successfully", null);
}It compiled, and it sat on main from 2024 into 2026. What's wrong with it?
| Where you saw it | The analyzer |
|---|---|
| Gray "variable is never used" | IntelliJ, VS Code |
| Red squiggle in a .js file | ESLint |
| "Object is possibly 'undefined'" | TypeScript |
| GitHub email: vulnerable library | Dependabot, CodeQL |
| "Quality gate failed" at your internship | SonarQube |
Static analysis: a program that reads your code without running it.
Developers give up on them over the noise. Johnson et al., ICSE 2013
if ((err = SSLHashSHA1.update(&hashCtx, &signedParams)) != 0)
goto fail;
goto fail;
if ((err = SSLHashSHA1.final(&hashCtx, &hashOut)) != 0)
goto fail;
CVE-2014-1266, iOS and macOS, February 2014
Some mistakes are too boring for people to catch. A tool does not get bored.
A tool that does not fail the build is decoration.
If a cheap check can reject the change, don't spend the expensive one.
| Asks | Cannot see | The story | |
|---|---|---|---|
| Static analysis | Does any path break a known rule? | What the code is for | goto fail |
| Testing | Does it do what we checked, on the inputs we ran? | Cases nobody wrote | CrowdStrike |
| Code review | Is this what the use case asks for? | Line 600 of a tired afternoon | Heartbleed |
Each one is blind where another looks.
Each slice stops what the others let through.
Analyzer
Same code, same findings, every run.
Every file, in seconds, no tokens.
Blind to intent.
Model
Ask twice, get two answers.
Reads what fits in context, paid per run.
Can judge intent.
Veracode, 2025: 100+ AI models, 45% of their code failed security tests.
One loop: the analyzer finds, the agent fixes, the analyzer checks again.
No analyzer is both sound and complete. One that claims to be is claiming to solve the halting problem.
False positive
A warning on code that is fine. Costs trust.
Not complete.
False negative
A bug the tool never mentions. A clean report is not proof.
Not sound.
Heartbleed: Coverity missed it too.
Google's rule: if developers ignore more than 1 in 10 of a check's warnings, the check is fixed or turned off.
An analyzer that flags every line has zero false negatives.
A static analyzer that checks code against a catalog of rules, and reports each match by rule, file, and line
Named after lint (Bell Labs, 1978): the fluff a lint brush picks off your sweater.
Backend
./mvnw compile
./mvnw ...spotbugs-maven-plugin:4.10.5.0:spotbugs
./mvnw ...maven-pmd-plugin:3.28.0:pmd
Frontend
npx eslint . --ignore-path ../.gitignore
npm run type-check
npx prettier --check src/
| Rule | What it means | Findings |
|---|---|---|
| EI_EXPOSE_REP2 | a setter keeps a mutable object, not a copy | 117 |
| EI_EXPOSE_REP | a getter hands out a mutable object | 102 |
| NP_NULL_ON_SOME_PATH... | uses a result that may be null | 14 |
| CT_CONSTRUCTOR_THROW | a constructor can throw halfway | 3 |
| DLS_DEAD_LOCAL_STORE | a value stored, never used | 1 |
The big rules are one decision. The bug is the one line at the bottom.
public List<Rating> getRatings() {
return ratings; // REP: hands out its own list
}
public void setRatings(List<Rating> ratings) {
this.ratings = ratings; // REP2: keeps the caller's list
...
calculateTotalScore();
}The caller adds a rating to the list. The total never changes.
public class Account {
private final long balance;
public Account(long balance) {
if (balance < 0) throw new IllegalArgumentException();
this.balance = balance;
}
}A subclass can rescue the half-built object. final closes the hole.
| Rule | What it means | Findings |
|---|---|---|
| UncommentedEmptyConstructor | empty constructor, no comment why | 30 |
| ControlStatementBraces | an if with no braces | 30 |
| UseLocaleWithCaseConversions | toLowerCase() without a Locale | 18 |
| InstantiableUtilityClass | static-only class you can new | 12 |
| UnnecessaryConstructor | a constructor Java writes anyway | 10 |
| 10 more rules | literals first, == on objects, style... | 30 |
74 of 130 are four project decisions. The bugs are in the small rules.
if (!this.enabled) return; // Reminders are disabledCorrect today. The next person adds a second line under it.
"%" + providedFirstName.toLowerCase() + "%"Empty constructors (40)
public Activity() {
}
JPA needs it. Comment it, or drop the rule?
GuardLogStatement (part of 30)
LOGGER.error("Could not send ... {} in section {}",
student.getEmail(), section.getSectionName(), e);
SLF4J's {} already defers the string. Guard it anyway?
private <T> void updateCollection(Set<T> oldCollection, Set<T> newCollection) {
oldCollection.clear();
...
}The tool sees the method is unused. Only the history says why.
| Rule | Before | After |
|---|---|---|
| SimplifyBooleanReturns | if (x == null) { return false; } return ok(x); |
return x != null && ok(x); |
| LambdaCanBeMethodReference | a -> a.getAuthority() |
GrantedAuthority::getAuthority |
| UselessParentheses | (MyUserPrincipal) (auth.getPrincipal()) |
(MyUserPrincipal) auth.getPrincipal() |
| UnnecessaryImport | import ...UserDetails; (unused) |
delete it |
| InstantiableUtilityClass | new StudentSpecs() compiles |
private StudentSpecs() {} |
All true, all harmless. A team decides once, then a formatter or the agent does the rest.
SpotBugs reads bytecode: 237
Null paths, casts, comparisons, mutable objects escaping
PMD reads source: 130
Locale bugs, unused code, risky idioms, style
.anyMatch(a -> a.getAuthority().equals("ROLE_instructor"));
PMD: put the literal first. SpotBugs: getAuthority() may be null. Two rules, one worry: when both point at a line, look.
Found by both: 1 of 367, the dead store.
| Verdict | Record it as |
|---|---|
| Real | A fix, and a test if you can show the bug |
| False positive | A suppression at the site, with the reason |
| Not worth it | A suppression, or the rule off, with the reason |
A verdict in your head gets made again, by the next person or the agent.
@PatchMapping("/{activityId}/comments")
public Result addActivityComment(...) {
String comment = commentMap.get("comment");
Activity updatedActivity = this.activityService.addActivityComment(activityId, comment);
ActivityDto updatedActivityDto = this.activityToActivityDtoConverter.convert(updatedActivity);
return new Result(true, StatusCode.SUCCESS, "Add comment successfully", null);
}Delete the line, or return the DTO? Who decides?
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
boolean hasInstructorRole = authentication.getAuthorities().stream() ...
Spring Security 7: @Nullable Authentication getAuthentication();
Behind the security filter chain it's never null. Is every caller behind it?
public void transferTeam(Team team, Section from, Section to) {
if (team.getSection() != from) {
throw new IllegalStateException("Team is not in the expected old section.");
}"Fix" this to .equals() and you change what it means.
try {
const result = await searchRequirementArtifacts(...)
glossaryTerms.value = result.data.content
} catch {
}
The Axios interceptor already showed the error.
"Not worth it" needs a reason about this codebase, or it is a false positive nobody checked.
@SuppressFBWarnings(value = "NP_NULL_ON_SOME_PATH_FROM_RETURN_VALUE",
justification = "Only called on authenticated routes")
// eslint-disable-next-line no-empty -- error already shown by the interceptorA suppression without a reason is a bug report you deleted.
| Where | What | Why |
|---|---|---|
| Editor | IntelliJ, SonarQube for IDE, ESLint | Seconds |
| Before you push | ./mvnw verify, frontend checks | Same as the PR, a minute sooner |
| Pull request | mvn verify, ESLint, Hadolint, CodeQL | The gate |
| Schedule | Dependabot, CodeQL on main | New CVEs, old code |
Shift left: the earlier the check, the cheaper the fix.
A CI job that fixes the code changes what it was asked to check.
A red check nobody is required to fix is advice.
"My code has zero warnings." "How?" "I turned them off."
<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.10.5.0</version>
<configuration>
<excludeFilterFile>${project.basedir}/config/spotbugs-exclude.xml,${project.basedir}/config/spotbugs-baseline.xml</excludeFilterFile>
</configuration>
<executions>
<execution><goals><goal>check</goal></goals></execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-pmd-plugin</artifactId>
<version>3.28.0</version>
<configuration>
<rulesets><ruleset>${project.basedir}/config/pmd-ruleset.xml</ruleset></rulesets>
<excludeFromFailureFile>${project.basedir}/config/pmd-baseline.properties</excludeFromFailureFile>
<printFailingErrors>true</printFailingErrors>
</configuration>
<executions>
<execution><goals><goal>check</goal></goals></execution>
</executions>
</plugin>
Baselines: 237 SpotBugs matches, 130 PMD findings in 68 classes. Frontend checks report only, for now; Actions pinned to SHAs.
check runs in verify, not test. New project? Drop the two baseline lines.
public boolean isAdmin(String role) {
return role == "admin";
}SpotBugs
Class, method, rule. A new finding in another method fails.
PMD
Class and rule. A new empty catch in a class that had one passes.
On new code, the best baseline is the one you never need.
| What you decided | Where |
|---|---|
| Tools, gate command, how findings are settled | Architecture-of-record, section 8.2 |
| The rule the agent obeys | Your charter, one line citing 8.2 |
| A rule turned off, and why | The tool's config, as a comment |
| A baseline | Section 11, as debt |
Not the specification, and not a key decision.
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
Result handleOtherException(Exception ex) {
return new Result(false, StatusCode.INTERNAL_SERVER_ERROR,
"A server internal error occurs.", ex.getMessage());
}Every unexpected exception ends here. What's missing?
//MethodDeclaration
[ModifierList/Annotation[@SimpleName = 'ExceptionHandler']]
[ModifierList/Annotation[@SimpleName = 'ResponseStatus']
//FieldAccess[@Name = 'INTERNAL_SERVER_ERROR']]
[not(.//MethodCall[@MethodName = ('error', 'warn')]
[*[1][matches(@Name, '^(log|logger|LOG|LOGGER)$')]])]
PMD custom rule, in backend/config/pmd-ruleset.xml
./mvnw verify -DskipTests: fails on handleOtherException
Add a logger and LOGGER.error("Unhandled exception", ex);: passes
A rule is code. Test it with one example it must flag and one it must pass.
A reviewer who writes the same comment three times is a linter with feelings.
Narrow the rule, or suppress that site with its reason.
| Tool | The rule is | Good for |
|---|---|---|
| PMD | XPath over the Java syntax tree | Conventions, in your build |
| Semgrep | A pattern that looks like the code | Many languages |
| ArchUnit | A JUnit test over packages | "No controller calls a repository" |
| ESLint | An AST selector in the config | Frontend conventions |
The agent
Drafts the XPath or Semgrep rule in a minute.
You
Choose the convention. Write the two examples.
Writing a rule used to cost more than repeating the comment. Now it doesn't.
public Integer getUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return null;
...
return null;
}
NullAway: 14 errors in team.projectpulse.system
// CourseService
public @Nullable Course findCourseById(Integer courseId) {
return this.courseRepository.findById(courseId).orElse(null);
}
// CourseController
Course course = this.courseService.findCourseById(courseId);
CourseDto courseDto = this.courseToCourseDtoConverter.convert(course);
CourseController.java:[46,71] passing @Nullable parameter 'course' where @NonNull is required
"Can this be null?" is undecidable. "Did you check the @Nullable value?" is not.
Tony Hoare called null "my billion-dollar mistake." NullAway is the refund desk.
flowchart LR
S[Source: request parameter] --> Z{Sanitizer?}
Z -->|parameterized query| OK[Safe]
Z -->|string concatenation| K[Sink: SQL string]
CodeQL default setup: free on public repositories, Java needs no build.
.csrf(csrf -> csrf.disable())
java/spring-disabled-csrf-protection · High · SecurityConfiguration
Is Project Pulse open to cross-site request forgery?
FROM eclipse-temurin:21-jre
WORKDIR /application
COPY --from=builder /builder/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "application.jar"]
Hadolint: no findings. The app ran as root (fall 2026).
March 2026: 75 of 76 trivy-action tags moved to code that stole pipeline secrets.
Nobody's workflow file changed. About twelve hours.
Tag: the owner can move it
uses: actions/checkout@v4
SHA: nobody can
uses: actions/checkout@11d5960a... # v4.4.0
Dependabot proposes the next SHA; a person reviews it.
The best way into a secure pipeline is through its security scanner.
Delegate
Explain a rule. Fix the obvious ones and the style the team chose. Group the report. Write the test for a real one. Draft a custom rule.
Keep
Every suppression, every rule turned off, every baseline entry kept. Which conventions get a rule.
Charter: run ./mvnw verify before you say done; baselines only shrink; no suppression without a reason in the pull request.
Fix it, suppress it with a reason, or turn the rule off with a reason. Never just hide it.