diff --git a/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/doc/DocumentConsistency.java b/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/doc/DocumentConsistency.java index 719776a5..391e6729 100644 --- a/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/doc/DocumentConsistency.java +++ b/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/doc/DocumentConsistency.java @@ -104,6 +104,6 @@ private String describe(Document document, Optional value) { private Optional capture(Pattern pattern, String content) { Matcher matcher = pattern.matcher(content); - return matcher.find() ? Optional.of(matcher.group(1)) : Optional.empty(); + return matcher.find() ? Optional.ofNullable(matcher.group(1)) : Optional.empty(); } } diff --git a/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/text/MarkdownDocument.java b/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/text/MarkdownDocument.java index 11a07a2f..0abd93b1 100644 --- a/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/text/MarkdownDocument.java +++ b/claude-code-enforcer/src/main/java/io/github/adamw7/tools/enforcer/text/MarkdownDocument.java @@ -95,7 +95,13 @@ public boolean hasBody(String heading) { return index >= 0 && hasBodyAt(index); } - /** The headings from {@code wanted} that are present, in the order they appear in the document. */ + /** + * The headings from {@code wanted} that are present, in the order they first + * appear in the document, each listed once. A required heading that appears + * more than once is reported by its first occurrence, so the order comparison + * matches the de-duplicated set of present sections rather than reporting a + * spurious out-of-order failure. + */ public List headingsInOrder(List wanted) { Set required = new LinkedHashSet<>(wanted); List ordered = new ArrayList<>(); @@ -106,7 +112,7 @@ public List headingsInOrder(List wanted) { } private void addIfRequiredHeading(String line, boolean lineInsideFence, Set required, List ordered) { - if (!lineInsideFence && required.contains(line)) { + if (!lineInsideFence && required.remove(line)) { ordered.add(line); } } diff --git a/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/ClaudeMdFormatRuleTest.java b/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/ClaudeMdFormatRuleTest.java index c4fb311e..feb0b8af 100644 --- a/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/ClaudeMdFormatRuleTest.java +++ b/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/ClaudeMdFormatRuleTest.java @@ -271,6 +271,20 @@ void failsWhenRequiredSectionsAppearInTheWrongOrder() { assertTrue(exception.getMessage().contains("out of order"), exception.getMessage()); } + @Test + void doesNotReportOutOfOrderWhenARequiredSectionAppearsTwice() { + String duplicated = VALID_CONTENT + """ + ## Testing + More notes on testing. + """; + ClaudeMdFormatRule rule = ruleFor(duplicated); + rule.setEnforceSectionOrder(true); + + // The sections are in the configured order; a repeated "## Testing" must be + // counted once by its first occurrence, not flagged as out of order. + assertDoesNotThrow(rule::execute); + } + @Test void failsWhenALineExceedsTheMaximumLength() { ClaudeMdFormatRule rule = ruleFor(VALID_CONTENT + "\n" + "x".repeat(200) + "\n"); diff --git a/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/CrossDocConsistencyRuleTest.java b/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/CrossDocConsistencyRuleTest.java index 65f26ade..3a378ba3 100644 --- a/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/CrossDocConsistencyRuleTest.java +++ b/claude-code-enforcer/src/test/java/io/github/adamw7/tools/enforcer/doc/CrossDocConsistencyRuleTest.java @@ -68,6 +68,17 @@ void failsWithAClearMessageWhenAPatternHasNoCapturingGroup() { assertTrue(exception.getMessage().contains("must declare a capturing group"), exception.getMessage()); } + @Test + void treatsANonParticipatingOptionalGroupAsAbsent() { + CrossDocConsistencyRule rule = ruleFor("Uses proto3 here.", "No proto mentioned."); + rule.setConsistentPatterns(List.of("proto(\\d)?")); + + // The group is optional, so a bare "proto" match captures null. That must be + // treated as a mismatch against the concrete "3", not throw a NullPointerException. + EnforcerRuleException exception = assertThrows(EnforcerRuleException.class, rule::execute); + assertTrue(exception.getMessage().contains("'3'"), exception.getMessage()); + } + @Test void failsWhenAFileIsMissing() { CrossDocConsistencyRule rule = new CrossDocConsistencyRule();