Skip to content

Commit 4501f13

Browse files
build(server): enforce JSpecify nullness with NullAway
1 parent efbd269 commit 4501f13

1,260 files changed

Lines changed: 8516 additions & 4669 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/bright-ravens-check.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"hephaestus": patch
3+
---
4+
5+
Pull request responses no longer require a URL when the upstream provider does not supply one.

docs/contributor/coding-guidelines.mdx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,14 @@ These guidelines keep every service aligned. Keep changes focused. Mention any d
6767
- Expose dedicated DTO mappers in the package instead of sprinkling conversion logic across layers.
6868
- Treat `Optional` as a signal for missing data. Avoid returning `null` from repository methods.
6969

70+
### Null safety
71+
72+
- Add a JSpecify `@NullMarked` `package-info.java` to every handwritten Java package.
73+
- Bare types are non-null. Use `@Nullable` only when absence is part of the contract. On API DTOs, use explicit `@NonNull` for components that must be `required` in OpenAPI.
74+
- Type-use annotations describe exactly what is nullable: `List<@Nullable String>` permits null elements; `String @Nullable []` permits a null array reference.
75+
- Fix NullAway errors at the contract or implementation boundary. Suppressions and classes without an explicit null-marking scope fail the quality gate.
76+
- Run `pnpm run check:java-nullness` for the policy check and `cd server && ./mvnw test-compile -P'!quick' -DskipTests` for compiler analysis.
77+
7078
### Safe workflows
7179

7280
- Configuration overrides stay in `application-*.yml`. Do not commit `application-local.yml`.

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@
4848
"format": "pnpm run format:server && pnpm run format:client && pnpm run format:agents",
4949
"format:check": "pnpm run format:server:check && pnpm run format:client:check && pnpm run format:agents:check",
5050
"lint": "run-s lint:server lint:client lint:agents",
51-
"check": "run-s check:biome-pin check:server check:client check:agents test:agents check:stories check:story-sort check:components check:diagrams check:env check:contracts check:instructions docs:lint",
51+
"check": "run-s check:biome-pin check:java-nullness check:server check:client check:agents test:agents check:stories check:story-sort check:components check:diagrams check:env check:contracts check:instructions docs:lint",
5252
"typecheck": "pnpm run typecheck:webapp && pnpm run typecheck:scripts && pnpm run typecheck:agents",
5353
"db:draft-changelog": "scripts/db-utils.sh draft-changelog",
5454
"db:generate-erd-docs": "scripts/db-utils.sh generate-erd",
@@ -75,6 +75,7 @@
7575
"check:diagrams": "node scripts/check-mermaid-diagrams.ts",
7676
"check:env": "node scripts/check-env-defaults.ts && node scripts/check-env-roles.ts && node --test scripts/check-env-roles.test.ts",
7777
"check:biome-pin": "node scripts/check-biome-pin.ts",
78+
"check:java-nullness": "node scripts/check-java-nullness.ts && node --test scripts/check-java-nullness.test.ts",
7879
"check:instructions": "node scripts/check-agent-instructions.ts && node --test scripts/check-agent-instructions.test.ts"
7980
},
8081
"devDependencies": {
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import assert from "node:assert/strict";
2+
import test from "node:test";
3+
import { nullnessPolicyViolations } from "./check-java-nullness.ts";
4+
5+
const source = (content: string) => [{ path: "Example.java", content }];
6+
7+
await test("accepts unrelated suppressions", () => {
8+
assert.deepEqual(
9+
nullnessPolicyViolations(source('@SuppressWarnings({ "unchecked", "deprecation" })')),
10+
[],
11+
);
12+
});
13+
14+
await test("rejects direct and namespaced NullAway suppressions", () => {
15+
for (const warning of ["NullAway", "NullAway.Init", "NullAway.Optional"]) {
16+
assert.deepEqual(nullnessPolicyViolations(source(`@SuppressWarnings("${warning}")`)), [
17+
"Example.java",
18+
]);
19+
}
20+
assert.deepEqual(nullnessPolicyViolations(source('@java.lang.SuppressWarnings("NullAway")')), [
21+
"Example.java",
22+
]);
23+
});
24+
25+
await test("rejects suppression arrays, concatenation, and Unicode escapes", () => {
26+
const examples = [
27+
'@SuppressWarnings({ "unchecked", "NullAway" })',
28+
'@SuppressWarnings("Null" + "Away")',
29+
'@SuppressWarnings("Null\\u0041way")',
30+
];
31+
for (const example of examples) {
32+
assert.deepEqual(nullnessPolicyViolations(source(example)), ["Example.java"]);
33+
}
34+
});
35+
36+
await test("rejects suppression names hidden behind constants", () => {
37+
assert.deepEqual(nullnessPolicyViolations(source("@SuppressWarnings(NULL_AWAY)")), [
38+
"Example.java",
39+
]);
40+
});
41+
42+
await test("ignores the warning name outside SuppressWarnings", () => {
43+
assert.deepEqual(nullnessPolicyViolations(source('String checker = "NullAway";')), []);
44+
});

scripts/check-java-nullness.ts

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
#!/usr/bin/env node
2+
import { execFile } from "node:child_process";
3+
import { readFile } from "node:fs/promises";
4+
import { resolve } from "node:path";
5+
import { promisify } from "node:util";
6+
7+
const execFileAsync = promisify(execFile);
8+
const REPO_ROOT = resolve(import.meta.dirname, "..");
9+
const JAVA_SOURCE = /^server\/src\/(?:main|test)\/java\/.*\.java$/;
10+
11+
export interface JavaSource {
12+
readonly path: string;
13+
readonly content: string;
14+
}
15+
16+
function unicodeEscapes(source: string): string {
17+
return source.replace(/\\u+([0-9a-fA-F]{4})/g, (_, hex: string) =>
18+
String.fromCharCode(Number.parseInt(hex, 16)),
19+
);
20+
}
21+
22+
function suppressionBodies(source: string): readonly string[] {
23+
const text = unicodeEscapes(source);
24+
const bodies: string[] = [];
25+
const annotation = /@(?:java\.lang\.)?SuppressWarnings\s*\(/g;
26+
for (let match = annotation.exec(text); match !== null; match = annotation.exec(text)) {
27+
const start = annotation.lastIndex;
28+
let depth = 1;
29+
let quoted = false;
30+
let escaped = false;
31+
for (let index = start; index < text.length; index++) {
32+
const character = text[index];
33+
if (quoted) {
34+
if (escaped) escaped = false;
35+
else if (character === "\\") escaped = true;
36+
else if (character === '"') quoted = false;
37+
continue;
38+
}
39+
if (character === '"') quoted = true;
40+
else if (character === "(") depth++;
41+
else if (character === ")" && --depth === 0) {
42+
bodies.push(text.slice(start, index));
43+
annotation.lastIndex = index + 1;
44+
break;
45+
}
46+
}
47+
}
48+
return bodies;
49+
}
50+
51+
function stringValues(body: string): string {
52+
return [...body.matchAll(/"((?:\\.|[^"\\])*)"/g)]
53+
.map((match) => match[1]?.replaceAll(/\\(["\\])/g, "$1") ?? "")
54+
.join("");
55+
}
56+
57+
function violatesPolicy(body: string): boolean {
58+
if (stringValues(body).includes("NullAway")) return true;
59+
const withoutStrings = body.replaceAll(/"(?:\\.|[^"\\])*"/g, "");
60+
return !/^[\s{},+]*$/.test(withoutStrings);
61+
}
62+
63+
export function nullnessPolicyViolations(sources: readonly JavaSource[]): readonly string[] {
64+
return sources
65+
.filter(({ content }) => suppressionBodies(content).some(violatesPolicy))
66+
.map(({ path }) => path);
67+
}
68+
69+
async function main(): Promise<void> {
70+
const { stdout } = await execFileAsync(
71+
"git",
72+
["ls-files", "--cached", "--others", "--exclude-standard"],
73+
{
74+
cwd: REPO_ROOT,
75+
},
76+
);
77+
const paths = stdout.split("\n").filter((path) => JAVA_SOURCE.test(path));
78+
const sources = await Promise.all(
79+
paths.map(async (path) => ({
80+
path,
81+
content: await readFile(resolve(REPO_ROOT, path), "utf8"),
82+
})),
83+
);
84+
const suppressed = nullnessPolicyViolations(sources);
85+
if (suppressed.length > 0) {
86+
throw new Error(
87+
`NullAway suppressions and indirect suppression names are forbidden; fix the contract or implementation:\n${suppressed.map((path) => ` ${path}`).join("\n")}`,
88+
);
89+
}
90+
console.log(
91+
`Java nullness policy: ${sources.length} handwritten source file(s), no NullAway suppressions.`,
92+
);
93+
}
94+
95+
if (import.meta.main) await main();

server/.mvn/jvm.config

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,13 @@
11
--enable-native-access=ALL-UNNAMED
22
-XX:+EnableDynamicAgentLoading
33
-Xmx4g
4+
--add-exports=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED
5+
--add-exports=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
6+
--add-exports=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
7+
--add-exports=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
8+
--add-exports=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
9+
--add-exports=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
10+
--add-exports=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
11+
--add-exports=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
12+
--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
13+
--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED

server/AGENTS.md

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,17 @@ authorization logic.
7474
never log a token) · `@Transactional` on a controller (service layer only) · expose an entity from a
7575
controller.
7676

77+
## Null-safety
78+
79+
NullAway checks all handwritten production and test code in JSpecify mode; generated sources are
80+
excluded. Every new package needs a `package-info.java` containing
81+
`@org.jspecify.annotations.NullMarked`, and the build rejects missing null-marking scopes and
82+
`NullAway` suppressions. Use `@Nullable` only for genuine absence and place it on the precise type:
83+
`List<@Nullable String>` permits null elements; `String @Nullable []` permits a null array reference.
84+
Fix violations at the contract or implementation boundary. In tests, refine a nullable result once
85+
before using it rather than adding duplicate assertions. Run `./mvnw test -P'!quick'` after changing a
86+
nullness contract.
87+
7788
## Test tiers
7889

7990
| Tag | Runs | Command |
@@ -141,9 +152,9 @@ or on "the only" result, and never write cleanup that another test depends on ha
141152
`OpenAPIConfiguration.ALLOWED_DOMAIN_OBJECTS`. The webapp client then simply has no type for it, with
142153
no error anywhere. Domain types the API deliberately exposes (`ProblemDetail`, `PracticeBinding`, …)
143154
are there for this reason.
144-
- DTOs are records, and `@NonNull` (`org.jspecify.annotations`) on a component is what puts it in the
145-
generated schema's `required` list. A component the API may omit is left off that list — annotate it
146-
`@Nullable` or leave it bare; neither changes the spec.
155+
- DTOs are records. All bare components are non-null under `@NullMarked`; add JSpecify `@NonNull` when
156+
that component must also appear in the generated schema's `required` list. A component the API may
157+
omit is `@Nullable`, never bare.
147158
- **Never wrap a DTO component in `Optional<>`.** springdoc unwraps it to the value type but still marks
148159
it required, so the generated TypeScript declares it non-optional and its response transformer
149160
converts it unconditionally — a value the server never sends is typed as one it always sends. Use

server/openapi.yaml

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11905,7 +11905,6 @@ components:
1190511905
type: string
1190611906
description: Title of the pull request
1190711907
required:
11908-
- htmlUrl
1190911908
- id
1191011909
- isDraft
1191111910
- isMerged
@@ -11995,7 +11994,6 @@ components:
1199511994
- additions
1199611995
- commentsCount
1199711996
- deletions
11998-
- htmlUrl
1199911997
- id
1200011998
- isDraft
1200111999
- isMerged

server/pom.xml

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -111,15 +111,13 @@
111111
<openapi-generator.version>7.19.0</openapi-generator.version>
112112
<postgresql.version>42.7.11</postgresql.version>
113113
<therapi.version>0.15.0</therapi.version>
114+
<error-prone.version>2.50.0</error-prone.version>
115+
<nullaway.version>0.14.0</nullaway.version>
114116
<shedlock.version>5.16.0</shedlock.version>
115117
<bucket4j.version>8.19.0</bucket4j.version>
116118
</properties>
117119
<dependencies>
118-
<!-- JSpecify nullness annotations (@NonNull/@Nullable). The forward-looking standard
119-
adopted by Spring Framework 7 / Boot 4, replacing the deprecated
120-
org.springframework.lang.* annotations. Version managed by the Spring Boot BOM.
121-
A custom ModelConverter (JSpecifyRequiredModelConverter) teaches SpringDoc to read
122-
these for OpenAPI `required`, since swagger-core only recognises the Spring ones. -->
120+
<!-- JSpecify annotations also drive the OpenAPI required-field converter. -->
123121
<dependency>
124122
<groupId>org.jspecify</groupId>
125123
<artifactId>jspecify</artifactId>
@@ -498,6 +496,11 @@
498496
<!-- Suppress warnings for generated code using deprecated GitHub API values -->
499497
<compilerArgs>
500498
<arg>-Xlint:all,-deprecation</arg>
499+
<arg>-XDcompilePolicy=simple</arg>
500+
<arg>--should-stop=ifError=FLOW</arg>
501+
<arg>-XDaddTypeAnnotationsToSymbol=true</arg>
502+
<!-- javac requires the Error Prone plugin and its options in one compiler argument. -->
503+
<arg>-Xplugin:ErrorProne -XepDisableAllChecks -Xep:NullAway:ERROR -Xep:RequireExplicitNullMarking:ERROR -XepExcludedPaths:.*[\\/]target[\\/]generated-sources[\\/].* -XepOpt:NullAway:AnnotatedPackages=de.tum.cit.aet.hephaestus -XepOpt:NullAway:JSpecifyMode=true -XepOpt:NullAway:TreatGeneratedAsUnannotated=true -XepOpt:NullAway:HandleTestAssertionLibraries=true</arg>
501504
</compilerArgs>
502505
<showWarnings>false</showWarnings>
503506
<annotationProcessorPaths>
@@ -514,6 +517,16 @@
514517
<artifactId>therapi-runtime-javadoc-scribe</artifactId>
515518
<version>${therapi.version}</version>
516519
</path>
520+
<path>
521+
<groupId>com.google.errorprone</groupId>
522+
<artifactId>error_prone_core</artifactId>
523+
<version>${error-prone.version}</version>
524+
</path>
525+
<path>
526+
<groupId>com.uber.nullaway</groupId>
527+
<artifactId>nullaway</artifactId>
528+
<version>${nullaway.version}</version>
529+
</path>
517530
</annotationProcessorPaths>
518531
</configuration>
519532
</plugin>

server/src/main/java/de/tum/cit/aet/hephaestus/account/AccountController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import io.swagger.v3.oas.annotations.tags.Tag;
77
import jakarta.validation.Valid;
88
import java.util.Optional;
9+
import org.jspecify.annotations.Nullable;
910
import org.slf4j.Logger;
1011
import org.slf4j.LoggerFactory;
1112
import org.springframework.http.HttpStatus;
@@ -76,7 +77,7 @@ public ResponseEntity<UserSettingsDTO> updateUserSettings(
7677
return ResponseEntity.ok(preferencesService.updateUserSettings(user.get(), userSettings, subjectId));
7778
}
7879

79-
private JwtAuthenticationToken resolveAuthentication(JwtAuthenticationToken injectedToken) {
80+
private @Nullable JwtAuthenticationToken resolveAuthentication(@Nullable JwtAuthenticationToken injectedToken) {
8081
if (injectedToken != null) {
8182
return injectedToken;
8283
}

0 commit comments

Comments
 (0)