From b4945af6e12acf980beb4490d88617905c804b4e Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 21:40:18 +0000 Subject: [PATCH 1/9] Reject combining categories() with excludeCategories() on text generators The engine schema honors only one of the two keys (verified against the real engine: with both sent, the exclusion is silently ignored), so TextGenerator emitted categories and dropped exclude_categories whenever both were configured. Throw IllegalArgumentException at configuration time instead of silently generating excluded characters. Co-Authored-By: Claude Fable 5 --- .../java/dev/hegel/generators/TextGenerator.java | 14 ++++++++++++++ .../java/dev/hegel/GeneratorValidationTest.java | 11 +++++++++++ 2 files changed, 25 insertions(+) diff --git a/src/main/java/dev/hegel/generators/TextGenerator.java b/src/main/java/dev/hegel/generators/TextGenerator.java index 6c0fcaa..7f3d4f8 100644 --- a/src/main/java/dev/hegel/generators/TextGenerator.java +++ b/src/main/java/dev/hegel/generators/TextGenerator.java @@ -34,6 +34,10 @@ public TextGenerator( String includeChars, String excludeChars) { Sizes.validate(minSize, maxSize, "text"); + if (categories != null && excludeCategories != null) { + throw new IllegalArgumentException("text: categories() and excludeCategories() cannot be combined;" + + " express the constraint one way, e.g. list only the allowed categories"); + } this.minSize = minSize; this.maxSize = maxSize; this.minCodepoint = minCodepoint; @@ -93,8 +97,12 @@ public TextGenerator codepoints(int min, int max) { /** * Restrict to the listed Unicode general categories (e.g. {@code "Lu"}, {@code "Nd"}). * + *

Cannot be combined with {@link #excludeCategories}: the engine honors only one of the two + * constraints, so express the restriction one way. + * * @param cats the allowed categories * @return a copy with the categories set + * @throws IllegalArgumentException if {@link #excludeCategories} was already set */ public TextGenerator categories(String... cats) { return new TextGenerator( @@ -109,8 +117,14 @@ public TextGenerator categories(String... cats) { } /** + * Exclude the listed Unicode general categories. + * + *

Cannot be combined with {@link #categories}: the engine honors only one of the two + * constraints, so express the restriction one way. + * * @param cats categories to exclude * @return a copy with excluded categories set + * @throws IllegalArgumentException if {@link #categories} was already set */ public TextGenerator excludeCategories(String... cats) { return new TextGenerator( diff --git a/src/test/java/dev/hegel/GeneratorValidationTest.java b/src/test/java/dev/hegel/GeneratorValidationTest.java index 4d55efa..4abb481 100644 --- a/src/test/java/dev/hegel/GeneratorValidationTest.java +++ b/src/test/java/dev/hegel/GeneratorValidationTest.java @@ -64,6 +64,17 @@ void textConstraints() { IllegalArgumentException.class, () -> text().categories("C").asBasic()); } + @Test + void textCategoriesAndExcludeCategoriesConflict() { + // The engine schema honors only one of categories/exclude_categories, so combining them + // must fail loudly instead of silently ignoring the exclusion (in either call order). + assertThrows( + IllegalArgumentException.class, () -> text().categories("L").excludeCategories("Lu")); + assertThrows( + IllegalArgumentException.class, + () -> text().excludeCategories("Lu").categories("L")); + } + @Test void durationBounds() { assertThrows( From dc151265534fa2afb75ef71de01e3e0caca0dc84 Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 21:48:22 +0000 Subject: [PATCH 2/9] Reject negative maxSize on text, binary, list, set, and map generators maxSize(-1) silently meant "unbounded" because -1 is the C ABI's internal UNBOUNDED sentinel; any other negative value already threw. The public fluent setters now reject all negative values with an IllegalArgumentException telling the user to omit the call for no bound. The sentinel stays internal (factory methods still use it). Co-Authored-By: Claude Fable 5 --- .../java/dev/hegel/generators/BinaryGenerator.java | 4 ++-- .../java/dev/hegel/generators/ListGenerator.java | 4 ++-- .../java/dev/hegel/generators/MapGenerator.java | 4 ++-- .../java/dev/hegel/generators/SetGenerator.java | 4 ++-- src/main/java/dev/hegel/generators/Sizes.java | 13 +++++++++++++ .../java/dev/hegel/generators/TextGenerator.java | 4 ++-- .../java/dev/hegel/GeneratorValidationTest.java | 14 ++++++++++++++ 7 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/main/java/dev/hegel/generators/BinaryGenerator.java b/src/main/java/dev/hegel/generators/BinaryGenerator.java index f2c35e0..9581d04 100644 --- a/src/main/java/dev/hegel/generators/BinaryGenerator.java +++ b/src/main/java/dev/hegel/generators/BinaryGenerator.java @@ -31,11 +31,11 @@ public BinaryGenerator minSize(int minSize) { } /** - * @param maxSize the maximum length (inclusive) + * @param maxSize the maximum length (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public BinaryGenerator maxSize(int maxSize) { - return new BinaryGenerator(minSize, maxSize); + return new BinaryGenerator(minSize, Sizes.checkedMax(maxSize, "binary")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/ListGenerator.java b/src/main/java/dev/hegel/generators/ListGenerator.java index 9da3caf..3cadaa3 100644 --- a/src/main/java/dev/hegel/generators/ListGenerator.java +++ b/src/main/java/dev/hegel/generators/ListGenerator.java @@ -37,11 +37,11 @@ public ListGenerator minSize(int minSize) { } /** - * @param maxSize the maximum length (inclusive) + * @param maxSize the maximum length (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public ListGenerator maxSize(int maxSize) { - return new ListGenerator<>(element, minSize, maxSize); + return new ListGenerator<>(element, minSize, Sizes.checkedMax(maxSize, "lists")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/MapGenerator.java b/src/main/java/dev/hegel/generators/MapGenerator.java index 92e7c3b..bd679db 100644 --- a/src/main/java/dev/hegel/generators/MapGenerator.java +++ b/src/main/java/dev/hegel/generators/MapGenerator.java @@ -42,11 +42,11 @@ public MapGenerator minSize(int minSize) { } /** - * @param maxSize the maximum entry count (inclusive) + * @param maxSize the maximum entry count (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public MapGenerator maxSize(int maxSize) { - return new MapGenerator<>(keys, values, minSize, maxSize); + return new MapGenerator<>(keys, values, minSize, Sizes.checkedMax(maxSize, "maps")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/SetGenerator.java b/src/main/java/dev/hegel/generators/SetGenerator.java index f936193..ad0b89a 100644 --- a/src/main/java/dev/hegel/generators/SetGenerator.java +++ b/src/main/java/dev/hegel/generators/SetGenerator.java @@ -37,11 +37,11 @@ public SetGenerator minSize(int minSize) { } /** - * @param maxSize the maximum size (inclusive) + * @param maxSize the maximum size (inclusive, {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public SetGenerator maxSize(int maxSize) { - return new SetGenerator<>(element, minSize, maxSize); + return new SetGenerator<>(element, minSize, Sizes.checkedMax(maxSize, "sets")); } /** @hidden */ diff --git a/src/main/java/dev/hegel/generators/Sizes.java b/src/main/java/dev/hegel/generators/Sizes.java index 3246a72..98f24d8 100644 --- a/src/main/java/dev/hegel/generators/Sizes.java +++ b/src/main/java/dev/hegel/generators/Sizes.java @@ -6,6 +6,19 @@ final class Sizes { private Sizes() {} + /** + * Validate a user-supplied maximum size from a public fluent setter. Rejects every negative + * value so the engine's internal "unbounded" sentinel ({@code -1}) can never be smuggled in + * through the public API. + */ + static long checkedMax(long maxSize, String what) { + if (maxSize < 0) { + throw new IllegalArgumentException( + what + ": maxSize must be >= 0, got " + maxSize + "; omit the maxSize call for no upper bound"); + } + return maxSize; + } + static void validate(long minSize, long maxSize, String what) { if (minSize < 0) { throw new IllegalArgumentException(what + ": minSize must be >= 0, got " + minSize); diff --git a/src/main/java/dev/hegel/generators/TextGenerator.java b/src/main/java/dev/hegel/generators/TextGenerator.java index 7f3d4f8..ef5c2cc 100644 --- a/src/main/java/dev/hegel/generators/TextGenerator.java +++ b/src/main/java/dev/hegel/generators/TextGenerator.java @@ -65,13 +65,13 @@ public TextGenerator minSize(int minSize) { } /** - * @param maxSize the maximum codepoint length + * @param maxSize the maximum codepoint length (must be {@code >= 0}; omit the call for no bound) * @return a copy with the maximum size set */ public TextGenerator maxSize(int maxSize) { return new TextGenerator( minSize, - maxSize, + Sizes.checkedMax(maxSize, "text"), minCodepoint, maxCodepoint, categories, diff --git a/src/test/java/dev/hegel/GeneratorValidationTest.java b/src/test/java/dev/hegel/GeneratorValidationTest.java index 4abb481..5330172 100644 --- a/src/test/java/dev/hegel/GeneratorValidationTest.java +++ b/src/test/java/dev/hegel/GeneratorValidationTest.java @@ -104,6 +104,20 @@ void collectionBounds() { () -> maps(integers(), integers()).minSize(5).maxSize(3)); } + @Test + void negativeMaxSizeRejected() { + // -1 is the engine's internal "unbounded" sentinel; passed to the public setters it used + // to silently lift the bound instead of failing. All negative values must be rejected. + assertThrows(IllegalArgumentException.class, () -> text().maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> binary().maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> lists(integers()).maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> sets(integers()).maxSize(-1)); + assertThrows( + IllegalArgumentException.class, + () -> maps(integers(), integers()).maxSize(-1)); + assertThrows(IllegalArgumentException.class, () -> text().maxSize(-2)); + } + @Test void selectionEmptiness() { assertThrows(IllegalArgumentException.class, () -> sampledFrom(List.of())); From 96142fdcf490d4ed1ccbc11023cd032b8a46c71d Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 21:54:03 +0000 Subject: [PATCH 3/9] Qualify default @HegelTest property names with the declaring class The default Settings name (which derives the example-database key) was the bare method name, so same-named test methods in different classes shared replay-database entries. The default is now the fully-qualified declaring class name plus the method name. Existing database entries stored under bare-method-name keys will no longer be found (a one-time invalidation); explicit name overrides are unaffected. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/hegel/HegelTest.java | 6 ++- .../java/dev/hegel/HegelTestExtension.java | 8 ++-- src/test/java/dev/hegel/CoverageTest.java | 40 +++++++++++++++---- 3 files changed, 41 insertions(+), 13 deletions(-) diff --git a/src/main/java/dev/hegel/HegelTest.java b/src/main/java/dev/hegel/HegelTest.java index dc56290..9344053 100644 --- a/src/main/java/dev/hegel/HegelTest.java +++ b/src/main/java/dev/hegel/HegelTest.java @@ -124,9 +124,11 @@ /** * Name for this property, used to derive a stable example-database key. Defaults to the test - * method name. + * method's fully-qualified name (declaring class name plus method name, e.g. {@code + * com.example.FooTest.myProperty}), so same-named methods in different classes get distinct + * database keys. * - * @return the property name, or {@code ""} to use the method name + * @return the property name, or {@code ""} to use the fully-qualified method name */ String name() default ""; } diff --git a/src/main/java/dev/hegel/HegelTestExtension.java b/src/main/java/dev/hegel/HegelTestExtension.java index 5a9bb5c..fc61498 100644 --- a/src/main/java/dev/hegel/HegelTestExtension.java +++ b/src/main/java/dev/hegel/HegelTestExtension.java @@ -74,14 +74,16 @@ public void interceptTestTemplateMethod( invocation.skip(); Method method = invocationContext.getExecutable(); HegelTest ann = method.getAnnotation(HegelTest.class); - Settings settings = settingsFrom(ann, method.getName()); + Settings settings = settingsFrom(ann, method); Object target = invocationContext.getTarget().orElse(null); Hegel.test(tc -> ReflectionSupport.invokeMethod(method, target, tc), settings); } } - static Settings settingsFrom(HegelTest ann, String methodName) { - String name = ann.name().isEmpty() ? methodName : ann.name(); + static Settings settingsFrom(HegelTest ann, Method method) { + // Qualify the default name with the declaring class: the name derives the example-database + // key, and a bare method name would collide across classes with same-named test methods. + String name = ann.name().isEmpty() ? method.getDeclaringClass().getName() + "." + method.getName() : ann.name(); Settings s = new Settings() .testCases(ann.testCases()) .verbosity(ann.verbosity()) diff --git a/src/test/java/dev/hegel/CoverageTest.java b/src/test/java/dev/hegel/CoverageTest.java index 759eb4e..ca47583 100644 --- a/src/test/java/dev/hegel/CoverageTest.java +++ b/src/test/java/dev/hegel/CoverageTest.java @@ -8,6 +8,7 @@ import static dev.hegel.Generators.text; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertSame; @@ -133,13 +134,13 @@ void hegelTestHelpers() throws Exception { assertTrue(HegelTestExtension.isTestCaseParam(TestCase.class)); assertFalse(HegelTestExtension.isTestCaseParam(String.class)); - Settings withSeed = HegelTestExtension.settingsFrom(seeded.getAnnotation(HegelTest.class), "s"); + Settings withSeed = HegelTestExtension.settingsFrom(seeded.getAnnotation(HegelTest.class), seeded); assertEquals(5L, withSeed.seed); assertTrue(withSeed.hasSeed); - // Defaults: no seed, no derandomize override, engine-default phases, default database, method - // name as the property name. - Settings noSeed = HegelTestExtension.settingsFrom(unseeded.getAnnotation(HegelTest.class), "u"); + // Defaults: no seed, no derandomize override, engine-default phases, default database, and + // the fully-qualified method name as the property name. + Settings noSeed = HegelTestExtension.settingsFrom(unseeded.getAnnotation(HegelTest.class), unseeded); assertFalse(noSeed.hasSeed); assertNull(noSeed.derandomize); assertNull(noSeed.phasesMask); @@ -147,12 +148,12 @@ void hegelTestHelpers() throws Exception { assertEquals(0, noSeed.suppressMask); assertEquals(Mode.TEST_RUN, noSeed.mode); assertFalse(noSeed.reportMultipleFailures); - assertEquals("u", noSeed.name); + assertEquals("dev.hegel.CoverageTest$Holder.unseeded", noSeed.name); // Fully-configured: derandomize forced on, a single explicit phase, a suppressed check, single // case and multi-failure modes, and a name override. Method configured = Holder.class.getDeclaredMethod("configured", TestCase.class); - Settings c = HegelTestExtension.settingsFrom(configured.getAnnotation(HegelTest.class), "ignored"); + Settings c = HegelTestExtension.settingsFrom(configured.getAnnotation(HegelTest.class), configured); assertEquals(Boolean.TRUE, c.derandomize); assertEquals(Integer.valueOf(Phase.GENERATE.bit), c.phasesMask); assertEquals(HealthCheck.TOO_SLOW.bit, c.suppressMask); @@ -163,18 +164,41 @@ void hegelTestHelpers() throws Exception { // derandomize forced off, an explicitly empty phase set (runs nothing — distinct from the // all-phases default), and the database disabled. Method emptyPhases = Holder.class.getDeclaredMethod("derandomFalseEmptyPhases", TestCase.class); - Settings e = HegelTestExtension.settingsFrom(emptyPhases.getAnnotation(HegelTest.class), "e"); + Settings e = HegelTestExtension.settingsFrom(emptyPhases.getAnnotation(HegelTest.class), emptyPhases); assertEquals(Boolean.FALSE, e.derandomize); assertEquals(Integer.valueOf(0), e.phasesMask); assertEquals(Database.Kind.DISABLED, e.database.kind); // A custom (compile-time) database path. Method customDb = Holder.class.getDeclaredMethod("customDb", TestCase.class); - Settings d = HegelTestExtension.settingsFrom(customDb.getAnnotation(HegelTest.class), "d"); + Settings d = HegelTestExtension.settingsFrom(customDb.getAnnotation(HegelTest.class), customDb); assertEquals(Database.Kind.PATH, d.database.kind); assertEquals("/tmp/hdb", d.database.path); } + // Two classes with identically-named test methods: their default property names (and hence + // example-database keys, which the engine derives from the name) must not collide. + static final class SameNameA { + @HegelTest + void prop(TestCase tc) {} + } + + static final class SameNameB { + @HegelTest + void prop(TestCase tc) {} + } + + @Test + void sameNamedMethodsInDifferentClassesGetDistinctDatabaseKeys() throws Exception { + Method a = SameNameA.class.getDeclaredMethod("prop", TestCase.class); + Method b = SameNameB.class.getDeclaredMethod("prop", TestCase.class); + Settings sa = HegelTestExtension.settingsFrom(a.getAnnotation(HegelTest.class), a); + Settings sb = HegelTestExtension.settingsFrom(b.getAnnotation(HegelTest.class), b); + assertNotEquals(sa.name, sb.name); + assertEquals("dev.hegel.CoverageTest$SameNameA.prop", sa.name); + assertEquals("dev.hegel.CoverageTest$SameNameB.prop", sb.name); + } + // --- Runner residual branches via fake --- private static void run(FakeLibhegel fake, java.util.function.Consumer body) { Runner.run( From c2fcc5e6e8c8ca861e6814a5429b0c85b0b9416c Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Fri, 10 Jul 2026 09:55:10 +0000 Subject: [PATCH 4/9] Document fromRegex's Python-re dialect and strengthen its smoke assertion Rebased remnant of "Document fromRegex's unanchored semantics and add a fullmatch overload": main's regex-fullmatch-default change (PR #9) has since made fromRegex generate whole-string matches by default with a fullmatch(boolean) opt-out, superseding the overload and the unanchored documentation. What remains from the original commit: document that patterns use the engine's Python re dialect (which differs from java.util.regex.Pattern in some constructs), and strengthen the previously-vacuous GeneratorSmokeTest assertion (length() >= 0) to pin the fullmatch semantics. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/hegel/Generators.java | 8 ++++++-- src/main/java/dev/hegel/generators/RegexGenerator.java | 3 +++ src/test/java/dev/hegel/GeneratorSmokeTest.java | 3 ++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/dev/hegel/Generators.java b/src/main/java/dev/hegel/Generators.java index e5d4c13..563ffae 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/src/main/java/dev/hegel/Generators.java @@ -592,9 +592,13 @@ public static DurationGenerator durations() { /** * Generates strings matching a (Python-compatible) regular expression. By default the entire * string matches the pattern; use {@link RegexGenerator#fullmatch(boolean) fullmatch(false)} - * to generate strings that merely contain a match. + * to generate strings that merely contain a match, with arbitrary padding on either + * side (the unanchored semantics of Hypothesis's {@code from_regex}). * - * @param pattern the regex pattern + *

The pattern uses the engine's Python {@code re} dialect, which differs from {@link + * java.util.regex.Pattern} in some constructs. + * + * @param pattern the regex pattern (Python {@code re} dialect) * @return a regex generator */ public static RegexGenerator fromRegex(String pattern) { diff --git a/src/main/java/dev/hegel/generators/RegexGenerator.java b/src/main/java/dev/hegel/generators/RegexGenerator.java index 21eb868..5f8d0e2 100644 --- a/src/main/java/dev/hegel/generators/RegexGenerator.java +++ b/src/main/java/dev/hegel/generators/RegexGenerator.java @@ -8,6 +8,9 @@ * Generates strings matching a (Python-compatible) regular expression. By default the entire * string matches the pattern; use {@link #fullmatch(boolean) fullmatch(false)} to generate strings * that merely contain a match. Always basic (one engine call). + * + *

The pattern uses the engine's Python {@code re} dialect, which differs from {@link + * java.util.regex.Pattern} in some constructs. */ public final class RegexGenerator implements Generator { private final String pattern; diff --git a/src/test/java/dev/hegel/GeneratorSmokeTest.java b/src/test/java/dev/hegel/GeneratorSmokeTest.java index aeae7b2..97629bb 100644 --- a/src/test/java/dev/hegel/GeneratorSmokeTest.java +++ b/src/test/java/dev/hegel/GeneratorSmokeTest.java @@ -125,7 +125,8 @@ void formatGenerators(TestCase tc) { assertTrue(tc.draw(times()).getHour() >= 0); assertTrue(tc.draw(datetimes()).getYear() >= 1); assertTrue(!tc.draw(durations()).isNegative()); - assertTrue(tc.draw(fromRegex("[a-z]{3}")).length() >= 0); + // Fullmatch semantics by default: the whole drawn string matches the pattern. + assertTrue(tc.draw(fromRegex("[a-z]{3}")).matches("[a-z]{3}")); } @HegelTest From fca5d519ac857e955453e08c83e53c2916713cdd Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 22:20:28 +0000 Subject: [PATCH 5/9] Generate negative Durations by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit durations() silently clamped its range to [0, Long.MAX_VALUE] nanoseconds even though java.time.Duration is signed (and Hypothesis's timedeltas covers negatives). The engine's integer schema accepts negative bounds (verified against the real engine), so the default range is now the full signed nanosecond range [Long.MIN_VALUE, Long.MAX_VALUE] (~±292 years) and min()/max() accept negative bounds. Use durations().min(Duration.ZERO) for the old non-negative behavior. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/hegel/Generators.java | 7 ++++--- .../dev/hegel/generators/DurationGenerator.java | 16 ++++++++-------- src/test/java/dev/hegel/ConformanceTest.java | 8 +++++++- src/test/java/dev/hegel/GeneratorSmokeTest.java | 2 +- .../java/dev/hegel/GeneratorValidationTest.java | 4 +++- 5 files changed, 23 insertions(+), 14 deletions(-) diff --git a/src/main/java/dev/hegel/Generators.java b/src/main/java/dev/hegel/Generators.java index 563ffae..54c4738 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/src/main/java/dev/hegel/Generators.java @@ -580,13 +580,14 @@ public static Generator zoneIds() { ZoneId.getAvailableZoneIds().stream().sorted().map(ZoneId::of).toList(); /** - * Generates {@link Duration} values across the representable nanosecond range. Configure with the - * fluent methods on {@link DurationGenerator}. + * Generates {@link Duration} values across the full signed nanosecond range — {@code + * [Long.MIN_VALUE, Long.MAX_VALUE]} nanoseconds, about ±292 years — including negative + * durations. Configure with the fluent methods on {@link DurationGenerator}. * * @return a duration generator */ public static DurationGenerator durations() { - return new DurationGenerator(0, Long.MAX_VALUE); + return new DurationGenerator(Long.MIN_VALUE, Long.MAX_VALUE); } /** diff --git a/src/main/java/dev/hegel/generators/DurationGenerator.java b/src/main/java/dev/hegel/generators/DurationGenerator.java index 6d24e61..ae523f7 100644 --- a/src/main/java/dev/hegel/generators/DurationGenerator.java +++ b/src/main/java/dev/hegel/generators/DurationGenerator.java @@ -9,18 +9,16 @@ * Generates {@link Duration} values within an inclusive {@code [min, max]} range. Always basic (one * engine call). * - *

Durations are drawn as a nanosecond count, so the representable range is {@code [0, - * Long.MAX_VALUE]} nanoseconds (about 292 years). The default is that whole range; narrow it with - * the fluent {@link #min(Duration)} / {@link #max(Duration)} methods. + *

Durations are drawn as a signed nanosecond count, so the representable range is {@code + * [Long.MIN_VALUE, Long.MAX_VALUE]} nanoseconds (about ±292 years), negative durations included. + * The default is that whole range; narrow it with the fluent {@link #min(Duration)} / {@link + * #max(Duration)} methods. */ public final class DurationGenerator implements Generator { private final long minNanos; private final long maxNanos; public DurationGenerator(long minNanos, long maxNanos) { - if (minNanos < 0) { - throw new IllegalArgumentException("durations: min must be non-negative"); - } if (minNanos > maxNanos) { throw new IllegalArgumentException("durations: min (" + minNanos + "ns) > max (" + maxNanos + "ns)"); } @@ -29,7 +27,8 @@ public DurationGenerator(long minNanos, long maxNanos) { } /** - * @param min the inclusive lower bound + * @param min the inclusive lower bound (may be negative; must fit in a {@code long} of + * nanoseconds) * @return a copy with the lower bound set */ public DurationGenerator min(Duration min) { @@ -37,7 +36,8 @@ public DurationGenerator min(Duration min) { } /** - * @param max the inclusive upper bound + * @param max the inclusive upper bound (may be negative; must fit in a {@code long} of + * nanoseconds) * @return a copy with the upper bound set */ public DurationGenerator max(Duration max) { diff --git a/src/test/java/dev/hegel/ConformanceTest.java b/src/test/java/dev/hegel/ConformanceTest.java index 556e0f1..8e62373 100644 --- a/src/test/java/dev/hegel/ConformanceTest.java +++ b/src/test/java/dev/hegel/ConformanceTest.java @@ -216,10 +216,16 @@ void temporalGeneratorsProduceJavaTimeTypes() { assertAllExamples(dates(), d -> d.getYear() >= 1 && d.getYear() <= 9999); assertAllExamples(times(), t -> t.getHour() >= 0 && t.getHour() <= 23); assertAllExamples(datetimes(), dt -> dt.getDayOfMonth() >= 1 && dt.getDayOfMonth() <= 31); - assertAllExamples(durations(), d -> !d.isNegative() && d.toNanos() <= Long.MAX_VALUE); + // durations() spans the full signed nanosecond range: negative values are reachable by + // default (Duration is signed, like Hypothesis's timedeltas)... + assertTrue(findAny(durations(), Duration::isNegative).isNegative()); + // ...and bounds are honored, on either side of zero. assertAllExamples( durations().min(Duration.ofSeconds(1)).max(Duration.ofSeconds(60)), d -> d.compareTo(Duration.ofSeconds(1)) >= 0 && d.compareTo(Duration.ofSeconds(60)) <= 0); + assertAllExamples( + durations().min(Duration.ofSeconds(-60)).max(Duration.ofSeconds(-1)), + d -> d.compareTo(Duration.ofSeconds(-60)) >= 0 && d.compareTo(Duration.ofSeconds(-1)) <= 0); } @Test diff --git a/src/test/java/dev/hegel/GeneratorSmokeTest.java b/src/test/java/dev/hegel/GeneratorSmokeTest.java index 97629bb..7c9ba1d 100644 --- a/src/test/java/dev/hegel/GeneratorSmokeTest.java +++ b/src/test/java/dev/hegel/GeneratorSmokeTest.java @@ -124,7 +124,7 @@ void formatGenerators(TestCase tc) { assertTrue(tc.draw(dates()).getYear() >= 1); assertTrue(tc.draw(times()).getHour() >= 0); assertTrue(tc.draw(datetimes()).getYear() >= 1); - assertTrue(!tc.draw(durations()).isNegative()); + assertTrue(!tc.draw(durations().min(java.time.Duration.ZERO)).isNegative()); // Fullmatch semantics by default: the whole drawn string matches the pattern. assertTrue(tc.draw(fromRegex("[a-z]{3}")).matches("[a-z]{3}")); } diff --git a/src/test/java/dev/hegel/GeneratorValidationTest.java b/src/test/java/dev/hegel/GeneratorValidationTest.java index 5330172..58bb6b5 100644 --- a/src/test/java/dev/hegel/GeneratorValidationTest.java +++ b/src/test/java/dev/hegel/GeneratorValidationTest.java @@ -80,7 +80,9 @@ void durationBounds() { assertThrows( IllegalArgumentException.class, () -> durations().min(java.time.Duration.ofSeconds(2)).max(java.time.Duration.ofSeconds(1))); - assertThrows(IllegalArgumentException.class, () -> durations().min(java.time.Duration.ofSeconds(-1))); + // Duration is signed, so negative bounds are legal. + durations().min(java.time.Duration.ofSeconds(-1)); + durations().min(java.time.Duration.ofSeconds(-2)).max(java.time.Duration.ofSeconds(-1)); } @Test From 1c9bad8b4c5b6dce82c83a862866d53234c41218 Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 22:21:53 +0000 Subject: [PATCH 6/9] Document that characters() generates one codepoint, not one char The javadoc said "single-character strings", but size is measured in codepoints: a supplementary-plane draw has String.length() == 2, which breaks naive charAt(0) use. Say so, recommend codePointAt(0), and note that further size calls replace the one-codepoint contract. Add a conformance test demonstrating length-2 single-codepoint values. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/hegel/Generators.java | 10 ++++++++-- src/test/java/dev/hegel/ConformanceTest.java | 11 +++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/main/java/dev/hegel/Generators.java b/src/main/java/dev/hegel/Generators.java index 54c4738..78b8009 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/src/main/java/dev/hegel/Generators.java @@ -113,9 +113,15 @@ public static TextGenerator text() { } /** - * Generates single-character strings. + * Generates strings of exactly one Unicode codepoint. A codepoint outside the Basic + * Multilingual Plane occupies two UTF-16 {@code char}s, so the generated string can have {@code + * String.length() == 2}; read the value with {@link String#codePointAt(int) codePointAt(0)} + * rather than {@code charAt(0)}. (Surrogates are excluded by default, as for {@link #text()}.) * - * @return a one-character text generator + *

The result is an ordinary {@link TextGenerator} with {@code minSize(1).maxSize(1)} applied, + * so calling further size methods (e.g. {@code minSize(0)}) replaces the one-codepoint contract. + * + * @return a one-codepoint text generator */ public static TextGenerator characters() { return text().minSize(1).maxSize(1); diff --git a/src/test/java/dev/hegel/ConformanceTest.java b/src/test/java/dev/hegel/ConformanceTest.java index 8e62373..67f8a61 100644 --- a/src/test/java/dev/hegel/ConformanceTest.java +++ b/src/test/java/dev/hegel/ConformanceTest.java @@ -97,6 +97,17 @@ void textRespectsLengthAndCharacters() { assertAllExamples(characters(), s -> cp(s) == 1); } + @Test + void charactersAreSingleCodepointsNotSingleChars() { + // characters() means one Unicode CODEPOINT. A supplementary-plane codepoint occupies two + // UTF-16 chars, so String.length() == 2 there; codePointAt(0) is the safe accessor. + assertAllExamples( + characters().codepoints(0x10000, 0x10FFF), + s -> s.codePointCount(0, s.length()) == 1 + && s.length() == 2 + && Character.isSupplementaryCodePoint(s.codePointAt(0))); + } + @Test void binaryRespectsLength() { assertAllExamples(binary().minSize(1).maxSize(4), b -> b.length >= 1 && b.length <= 4); From f4b3cf8deaa0b94a41ce6fd1628c339384cb1054 Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 22:23:12 +0000 Subject: [PATCH 7/9] Fix incorrect javadoc on datetimes() and forType() - datetimes() swapped the two zone methods: timezones(Generator) produces ZonedDateTime and offsets(Generator) produces OffsetDateTime, not the other way round. - forType(Class) advertised List/Set/Optional/Map support that the Class-based signature cannot express (those work only as record components, where the declared element types are known), and omitted the supported java.time scalars. Document the actual supported set, the record-component restriction, and the thrown HegelException; pin forType(List.class) throwing in DerivationTest. Co-Authored-By: Claude Fable 5 --- src/main/java/dev/hegel/Generators.java | 18 +++++++++++++----- src/test/java/dev/hegel/DerivationTest.java | 3 +++ 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/dev/hegel/Generators.java b/src/main/java/dev/hegel/Generators.java index 78b8009..59a07fb 100644 --- a/src/main/java/dev/hegel/Generators.java +++ b/src/main/java/dev/hegel/Generators.java @@ -469,14 +469,21 @@ public static Deferred deferred() { /** * Derive a generator for {@code type} by reflection. * - *

Supports scalar types ({@code int}, {@code long}, {@code boolean}, {@code float}, {@code - * double}, {@code String}, {@code byte[]}, {@link UUID}, and their wrappers), enums, records - * (recursively), and {@code List}, {@code Set}, {@code Optional} and {@code Map} of supported - * element types. + *

Supports the scalar types {@code int}, {@code long}, {@code boolean}, {@code float} and + * {@code double} (and their wrappers), {@code String}, {@code byte[]}, {@link UUID}, {@link + * Duration}, {@link LocalDate}, {@link LocalTime}, {@link java.time.LocalDateTime}, {@link + * java.time.OffsetDateTime}, {@link java.time.ZonedDateTime}, {@link ZoneOffset} and {@link + * ZoneId}, plus enums and records (recursively). + * + *

{@code List}, {@code Set}, {@code Optional} and {@code Map} of supported element types are + * derived only as record components, where the declared element types are known. This {@code + * Class}-based entry point cannot express a parameterized type, so e.g. {@code + * forType(List.class)} throws. * * @param type the type to derive a generator for * @param the type * @return a generator producing instances of {@code type} + * @throws HegelException if no generator can be derived for {@code type} */ @SuppressWarnings("unchecked") public static Generator forType(Class type) { @@ -552,7 +559,8 @@ public static Generator times() { /** * Generates {@link java.time.LocalDateTime} values (the engine's offset-free {@code * YYYY-MM-DDTHH:MM:SS[.ffffff]} output). Call {@link DateTimeGenerator#timezones} to produce - * offset-aware {@link java.time.OffsetDateTime} values instead. + * DST-aware {@link java.time.ZonedDateTime} values, or {@link DateTimeGenerator#offsets} to + * produce fixed-offset {@link java.time.OffsetDateTime} values instead. * * @return a datetime generator */ diff --git a/src/test/java/dev/hegel/DerivationTest.java b/src/test/java/dev/hegel/DerivationTest.java index cf1e3ca..b3bc66f 100644 --- a/src/test/java/dev/hegel/DerivationTest.java +++ b/src/test/java/dev/hegel/DerivationTest.java @@ -65,6 +65,9 @@ void perComponentOverride() { @Test void unsupportedTypeFailsClearly() { assertThrows(HegelException.class, () -> forType(Object.class)); + // Collections are supported only as record components (where the declared element type is + // known); the Class-based entry point cannot express them. + assertThrows(HegelException.class, () -> forType(List.class)); // Unsupported generic element type surfaces when the record is generated. assertThrows( HegelException.class, From 62b9a30f0da28be1814ff2a01a96391f07949bae Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 22:23:47 +0000 Subject: [PATCH 8/9] Update README installation snippets to the released version 0.4.0 The Maven and Gradle snippets still said 0.1.0; the latest release per the CHANGELOG (and the pom) is 0.4.0. Co-Authored-By: Claude Fable 5 --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5442985..e62dce0 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Add the dependency with Maven: dev.hegel hegel - 0.1.0 + 0.4.0 test ``` @@ -30,7 +30,7 @@ Add the dependency with Maven: or with Gradle: ```kotlin -testImplementation("dev.hegel:hegel:0.1.0") +testImplementation("dev.hegel:hegel:0.4.0") ``` Hegel for Java requires **Java 22+** and uses the [Foreign Function & Memory API](https://docs.oracle.com/en/java/javase/22/core/foreign-function-and-memory-api.html). The native engine is bundled in the jar for Linux (x86-64 and arm64) and macOS (Apple Silicon). From c8d9b06fe484af5d1fca700b47c4f71a95f6a8c8 Mon Sep 17 00:00:00 2001 From: "Claude (on behalf of David MacIver)" Date: Thu, 9 Jul 2026 22:23:47 +0000 Subject: [PATCH 9/9] Add RELEASE.md changelog entry for the API cleanup RELEASE_TYPE is minor per the zerover guidance (breaking changes only): the durations() range change, the categories()/excludeCategories() conflict now throwing, the negative-maxSize rejection, and the example-database key qualification can all break existing usage. The fromRegex bullet from the pre-rebase entry is gone: main's 0.4.0 release already shipped fullmatch-by-default fromRegex, superseding it; only the Python-re dialect documentation remains from that commit. Co-Authored-By: Claude Fable 5 --- RELEASE.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 RELEASE.md diff --git a/RELEASE.md b/RELEASE.md new file mode 100644 index 0000000..ceb3374 --- /dev/null +++ b/RELEASE.md @@ -0,0 +1,22 @@ +RELEASE_TYPE: minor + +This release fixes several public-API defects found in an audit: + +- `Generators.durations()` now covers the full signed nanosecond range, so negative `Duration` + values are generated by default and `min()`/`max()` accept negative bounds. Use + `durations().min(Duration.ZERO)` for the previous non-negative behavior. +- `TextGenerator` rejects combining `categories(...)` with `excludeCategories(...)` instead of + silently ignoring the exclusion (the engine honors only one of the two constraints). +- The text, binary, list, set, and map generators reject every negative `maxSize(...)` argument. + Previously `maxSize(-1)` silently meant "unbounded" (it collided with an internal sentinel). +- The default `@HegelTest` property name — which derives the example-database key — is now the + fully-qualified method name (declaring class plus method name), so same-named test methods in + different classes no longer share replay-database entries. Stored entries under the old + bare-method-name keys will no longer be found (a one-time invalidation); explicit + `name = "..."` overrides are unaffected. +- Javadoc corrections: `characters()` generates one Unicode codepoint (possibly two UTF-16 + `char`s — use `codePointAt(0)`); `datetimes()` had its two zone methods swapped + (`timezones(...)` produces `ZonedDateTime`, `offsets(...)` produces `OffsetDateTime`); + `forType(Class)` documents the actual supported scalar set and that `List`/`Set`/`Optional`/ + `Map` are derived only as record components; `fromRegex(...)` documents the engine's + Python-`re` pattern dialect.