# Defensive Programming Pass 1 Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** 以保守方式收敛两处明显的过度防御：把 `AchievementReportContent` 的 `hitWords` 归一化为非 null，不再让 renderer 对内部不变量重复判空；让 `AchievementReportContentMapper` 对不应出现的 `stageName == null` fail-fast，而不是静默兜底成空字符串。

**Architecture:** 本轮只收敛“内部不变量已建立后仍重复兜底”的分支，不改 HTTP 契约、不改输入校验总策略。实现上通过领域对象归一化 `hitWords` 消除下游 null 防御，再通过 mapper 的 fail-fast 去掉 `stageName` 空串兜底。

**Tech Stack:** Java 21, Maven, JUnit 5, AssertJ, Spring Boot

---

### Task 1: Normalize achievement hit words to an immutable non-null list

**Files:**
- Modify: `abilities/exam-sprint/domain/src/test/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContentTest.java`
- Modify: `abilities/exam-sprint/domain/src/main/java/cn/yunzhixue/ability/center/examsprint/domain/report/AchievementReportContent.java`
- Modify: `abilities/exam-sprint/infrastructure/src/main/java/cn/yunzhixue/ability/center/examsprint/infrastructure/report/rendering/achievement/ClasspathAchievementExamSprintReportRenderer.java`

**Step 1: Write the failing test**

在 `AchievementReportContentTest` 增加一个测试，断言当 `ExamUnknownWordsHitStatus` 传入 `null` 的 `hitWords` 时：
- `hitWords()` 返回空列表而不是 `null`
- 返回值不可变

测试名建议：`normalizesNullHitWordsToEmptyImmutableList`

**Step 2: Run test to verify it fails**

Run: `mvn -q -pl abilities/exam-sprint/domain -Dtest=AchievementReportContentTest#normalizesNullHitWordsToEmptyImmutableList test`

Expected: FAIL，因为当前实现会保留 `null`

**Step 3: Write minimal implementation**

在 `AchievementReportContent.ExamUnknownWordsHitStatus` 构造器中，把 `hitWords` 归一化为 `List.of()` 或 `List.copyOf(...)` 的结果，保证字段永远非 null 且不可变。

然后在 `ClasspathAchievementExamSprintReportRenderer.renderHitWords(...)` 中删掉对 `hitStatus == null` 和 `hitStatus.hitWords() == null` 的重复防御，只保留对空列表的空态渲染。

**Step 4: Run test to verify it passes**

Run: `mvn -q -pl abilities/exam-sprint/domain -Dtest=AchievementReportContentTest#normalizesNullHitWordsToEmptyImmutableList test`

Expected: PASS

**Step 5: Run focused regression tests**

Run: `mvn -q -pl abilities/exam-sprint/infrastructure -am -Dtest=ClasspathAchievementExamSprintReportRendererTest test`

Expected: PASS，尤其覆盖“hitWords 为空 / null 时渲染空态”的现有行为

### Task 2: Fail fast on impossible null stage name in achievement mapper

**Files:**
- Modify: `abilities/exam-sprint/application/src/test/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapperTest.java`
- Modify: `abilities/exam-sprint/application/src/main/java/cn/yunzhixue/ability/center/examsprint/application/report/AchievementReportContentMapper.java`

**Step 1: Write the failing test**

在 `AchievementReportContentMapperTest` 增加一个测试，构造 `AchievementExamSprintReportPayload`，其中 `stageName` 为 `null`，并断言 `toDomainContent(...)` 抛出 `NullPointerException` 且消息包含 `stageName`。

测试名建议：`rejectsNullStageNameInsteadOfSilentlyFallingBack`

**Step 2: Run test to verify it fails**

Run: `mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest#rejectsNullStageNameInsteadOfSilentlyFallingBack test`

Expected: FAIL，因为当前实现会把 `null` 兜底为 `""`

**Step 3: Write minimal implementation**

在 `AchievementReportContentMapper.reportTitle(...)` 中用 `Objects.requireNonNull(stageName, "stageName")` 明确内部不变量，再对非 null 字符串做 `trim()`。

不要顺手调整其他 mapper/null 策略。

**Step 4: Run test to verify it passes**

Run: `mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest#rejectsNullStageNameInsteadOfSilentlyFallingBack test`

Expected: PASS

**Step 5: Run focused regression tests**

Run: `mvn -q -pl abilities/exam-sprint/application -am -Dtest=AchievementReportContentMapperTest test`

Expected: PASS

### Task 3: Final verification for the conservative pass

**Files:**
- Verify only; no additional code changes expected

**Step 1: Run the targeted module tests together**

Run: `mvn -q -pl abilities/exam-sprint/domain,abilities/exam-sprint/application,abilities/exam-sprint/infrastructure -am -Dtest=AchievementReportContentTest,AchievementReportContentMapperTest,ClasspathAchievementExamSprintReportRendererTest test`

Expected: PASS

**Step 2: Run full repository tests**

Run: `mvn test -q`

Expected: PASS

**Step 3: Review diff for scope control**

Run: `git diff -- abilities/exam-sprint/domain abilities/exam-sprint/application abilities/exam-sprint/infrastructure docs/plans/2026-04-30-defensive-programming-pass1.md`

Expected: 只包含计划内的保守收敛改动
