From 188e32824024ae8d52b827b612f8ecb8f56c4f9a Mon Sep 17 00:00:00 2001 From: Laurynas Keturakis Date: Wed, 25 Mar 2026 19:04:48 +0100 Subject: [PATCH 1/2] test: explore property and fuzz testing strategy Tackle: - define link/unlink/relink round-trip properties - fuzz inline anchor parsing around markdown/code contexts - compare property coverage to the current unit/integration matrix From a5c59319a4b05baf536a14b49d14dcf126eee5b0 Mon Sep 17 00:00:00 2001 From: Laurynas Keturakis Date: Wed, 25 Mar 2026 19:36:08 +0100 Subject: [PATCH 2/2] test: expand fuzz seed corpus and split property harness Split the exploratory fuzz work into: - test/fuzz/corpus_test.zig for explicit seed/regression cases - test/fuzz/property_test.zig for randomized invariant checks - test/fuzz/helpers.zig for generators and shared helpers - fuzz_tests.zig as the standalone harness The expanded corpus covers: - richer path and symbol shapes - mixed markdown/code-context wrappers - fixed frontmatter/comment relink seeds - targeted inline rewrite invariants This keeps known tricky cases readable while still using randomized properties to widen coverage. --- fuzz_tests.zig | 4 + src/frontmatter.zig | 22 ++++ test/fuzz/corpus_test.zig | 123 +++++++++++++++++++ test/fuzz/helpers.zig | 178 +++++++++++++++++++++++++++ test/fuzz/property_test.zig | 236 ++++++++++++++++++++++++++++++++++++ 5 files changed, 563 insertions(+) create mode 100644 fuzz_tests.zig create mode 100644 test/fuzz/corpus_test.zig create mode 100644 test/fuzz/helpers.zig create mode 100644 test/fuzz/property_test.zig diff --git a/fuzz_tests.zig b/fuzz_tests.zig new file mode 100644 index 0000000..bd93fd0 --- /dev/null +++ b/fuzz_tests.zig @@ -0,0 +1,4 @@ +test { + _ = @import("test/fuzz/property_test.zig"); + _ = @import("test/fuzz/corpus_test.zig"); +} diff --git a/src/frontmatter.zig b/src/frontmatter.zig index b2936e6..d080763 100644 --- a/src/frontmatter.zig +++ b/src/frontmatter.zig @@ -320,6 +320,10 @@ fn linkCommentAnchor(allocator: std.mem.Allocator, content: []const u8, anchor: continue; } + if (in_files_section and trimmed.len == 0) { + continue; + } + if (in_files_section and trimmed.len > 0 and !std.mem.startsWith(u8, trimmed, "- ")) { if (!found_existing and !wrote_anchor) { try writer.writeAll(" - "); @@ -1056,6 +1060,24 @@ test "linkAnchor adds to comment-based anchor" { try std.testing.expect(std.mem.indexOf(u8, result, "src/new.ts@abc") != null); } +test "linkAnchor is idempotent for comment-based anchors" { + const allocator = std.testing.allocator; + const content = + "# Doc\n\n" ++ + "\n"; + + const first = try linkAnchor(allocator, content, "src/new.ts@abc"); + defer allocator.free(first); + + const second = try linkAnchor(allocator, first, "src/new.ts@abc"); + defer allocator.free(second); + + try std.testing.expectEqualStrings(first, second); +} + test "linkAnchor preserves comment-based drift when unrelated frontmatter exists" { const allocator = std.testing.allocator; const content = diff --git a/test/fuzz/corpus_test.zig b/test/fuzz/corpus_test.zig new file mode 100644 index 0000000..287853e --- /dev/null +++ b/test/fuzz/corpus_test.zig @@ -0,0 +1,123 @@ +const std = @import("std"); +const helper = @import("helpers.zig"); + +const frontmatter = helper.frontmatter; +const scanner = helper.scanner; + +test "corpus: inline refs ignore varied markdown code contexts" { + const allocator = std.testing.allocator; + const first = "src/deep/file.test.ts"; + const second = "src/.hidden/mod.impl.rs#Thing"; + const content = + "# Spec\n\n" ++ + "See (@./src/deep/file.test.ts) and <@./src/.hidden/mod.impl.rs#Thing>.\n\n" ++ + "``@./src/deep/file.test.ts`` should be ignored.\n\n" ++ + "~~~md\n@./src/.hidden/mod.impl.rs#Thing\n~~~\n\n" ++ + " ```ts\n" ++ + " @./src/deep/file.test.ts\n" ++ + " ```\n"; + + var anchors = scanner.parseInlineAnchors(allocator, content); + defer { + for (anchors.items) |anchor| allocator.free(anchor); + anchors.deinit(allocator); + } + + try std.testing.expectEqual(@as(usize, 2), anchors.items.len); + try std.testing.expectEqualStrings(first, anchors.items[0]); + try std.testing.expectEqualStrings(second, anchors.items[1]); + + const updated = try scanner.updateInlineAnchors(allocator, content, null, "corpus123"); + defer allocator.free(updated); + + try std.testing.expect(std.mem.indexOf(u8, updated, "(@./src/deep/file.test.ts@corpus123)") != null); + try std.testing.expect(std.mem.indexOf(u8, updated, "<@./src/.hidden/mod.impl.rs#Thing@corpus123>") != null); + try std.testing.expect(std.mem.indexOf(u8, updated, "``@./src/deep/file.test.ts``") != null); + try std.testing.expect(std.mem.indexOf(u8, updated, "~~~md\n@./src/.hidden/mod.impl.rs#Thing\n~~~") != null); + try std.testing.expect(std.mem.indexOf(u8, updated, " @./src/deep/file.test.ts\n ```") != null); +} + +test "corpus: punctuation wrapped refs parse and rewrite as expected" { + const allocator = std.testing.allocator; + const cases = [_]struct { + raw: []const u8, + parsed: []const u8, + rewritten: []const u8, + }{ + .{ + .raw = "\"@./src/app/main.test.ts\"", + .parsed = "src/app/main.test.ts", + .rewritten = "\"@./src/app/main.test.ts@seedwrap\"", + }, + .{ + .raw = "'@./src/lib/core.rs#Thing'", + .parsed = "src/lib/core.rs#Thing", + .rewritten = "'@./src/lib/core.rs#Thing@seedwrap'", + }, + .{ + .raw = "(@./src/tools/gen.zig)", + .parsed = "src/tools/gen.zig", + .rewritten = "(@./src/tools/gen.zig@seedwrap)", + }, + .{ + .raw = "<@./src/.hidden/mod.impl.py#Thing>", + .parsed = "src/.hidden/mod.impl.py#Thing", + .rewritten = "<@./src/.hidden/mod.impl.py#Thing@seedwrap>", + }, + }; + + for (cases) |case| { + const content = try std.fmt.allocPrint(allocator, "# Spec\n\nSee {s}.\n", .{case.raw}); + defer allocator.free(content); + + var anchors = scanner.parseInlineAnchors(allocator, content); + defer { + for (anchors.items) |anchor| allocator.free(anchor); + anchors.deinit(allocator); + } + + try std.testing.expectEqual(@as(usize, 1), anchors.items.len); + try std.testing.expectEqualStrings(case.parsed, anchors.items[0]); + + const updated = try scanner.updateInlineAnchors(allocator, content, null, "seedwrap"); + defer allocator.free(updated); + try std.testing.expect(std.mem.indexOf(u8, updated, case.rewritten) != null); + } +} + +test "corpus: relink preserves identities in fixed frontmatter and comment docs" { + const allocator = std.testing.allocator; + const anchors = [_][]const u8{ + "src/auth/login.test.ts", + "src/payments/.hidden/stripe.impl.rs#Config", + }; + const provenance = "c0ffee12"; + + const frontmatter_doc = try helper.renderFrontmatterDoc(allocator, &anchors); + defer allocator.free(frontmatter_doc); + const relinked_frontmatter = try frontmatter.relinkAllAnchors(allocator, frontmatter_doc, provenance); + defer allocator.free(relinked_frontmatter); + + var parsed_frontmatter = frontmatter.parseDriftSpec(allocator, relinked_frontmatter) orelse return error.TestUnexpectedResult; + defer { + for (parsed_frontmatter.items) |anchor| allocator.free(anchor); + parsed_frontmatter.deinit(allocator); + } + try std.testing.expectEqual(@as(usize, 2), parsed_frontmatter.items.len); + try helper.expectAnchorPresent(parsed_frontmatter.items, "src/auth/login.test.ts@c0ffee12"); + try helper.expectAnchorPresent(parsed_frontmatter.items, "src/payments/.hidden/stripe.impl.rs#Config@c0ffee12"); + + const comment_doc = try helper.renderCommentDoc(allocator, &anchors); + defer allocator.free(comment_doc); + const relinked_comment = try frontmatter.relinkAllAnchors(allocator, comment_doc, provenance); + defer allocator.free(relinked_comment); + + var parsed_comment = frontmatter.parseDriftSpec(allocator, relinked_comment) orelse return error.TestUnexpectedResult; + defer { + for (parsed_comment.items) |anchor| allocator.free(anchor); + parsed_comment.deinit(allocator); + } + try std.testing.expectEqual(@as(usize, 2), parsed_comment.items.len); + try helper.expectAnchorPresent(parsed_comment.items, "src/auth/login.test.ts@c0ffee12"); + try helper.expectAnchorPresent(parsed_comment.items, "src/payments/.hidden/stripe.impl.rs#Config@c0ffee12"); +} diff --git a/test/fuzz/helpers.zig b/test/fuzz/helpers.zig new file mode 100644 index 0000000..ef9e604 --- /dev/null +++ b/test/fuzz/helpers.zig @@ -0,0 +1,178 @@ +const std = @import("std"); + +pub const frontmatter = @import("../../src/frontmatter.zig"); +pub const scanner = @import("../../src/scanner.zig"); + +pub const Wrapper = struct { + prefix: []const u8, + suffix: []const u8, +}; + +pub fn appendRandomChars( + buf: *std.ArrayList(u8), + allocator: std.mem.Allocator, + random: std.Random, + alphabet: []const u8, + len: usize, +) !void { + for (0..len) |_| { + const idx = random.uintLessThan(usize, alphabet.len); + try buf.append(allocator, alphabet[idx]); + } +} + +pub fn randomAnchor(allocator: std.mem.Allocator, random: std.Random) ![]const u8 { + const path_chars = "abcdefghijklmnopqrstuvwxyz0123456789_-"; + const symbol_chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789_"; + const extensions = [_][]const u8{ ".ts", ".py", ".rs", ".zig" }; + const infixes = [_][]const u8{ ".test", ".spec", ".impl", ".gen" }; + + var buf: std.ArrayList(u8) = .{}; + defer buf.deinit(allocator); + + try buf.appendSlice(allocator, "src"); + + const segment_count = random.intRangeAtMost(usize, 1, 3); + for (0..segment_count) |segment_idx| { + try buf.append(allocator, '/'); + if (random.boolean()) { + try buf.append(allocator, '.'); + } + const segment_len = random.intRangeAtMost(usize, 3, 10); + try appendRandomChars(&buf, allocator, random, path_chars, segment_len); + if (segment_idx + 1 == segment_count and random.boolean()) { + const infix = infixes[random.uintLessThan(usize, infixes.len)]; + try buf.appendSlice(allocator, infix); + } + } + + const ext = extensions[random.uintLessThan(usize, extensions.len)]; + try buf.appendSlice(allocator, ext); + + if (random.boolean()) { + try buf.append(allocator, '#'); + const symbol_len = random.intRangeAtMost(usize, 3, 12); + try appendRandomChars(&buf, allocator, random, symbol_chars, symbol_len); + } + + return try allocator.dupe(u8, buf.items); +} + +pub fn baseDoc(allocator: std.mem.Allocator, variant: usize) ![]const u8 { + return switch (variant % 6) { + 0 => try allocator.dupe( + u8, + "# Spec\n\nSome prose.\n", + ), + 1 => try allocator.dupe( + u8, + "---\n" ++ + "drift:\n" ++ + " files:\n" ++ + "---\n" ++ + "# Spec\n", + ), + 2 => try allocator.dupe( + u8, + "---\n" ++ + "title: My Doc\n" ++ + "tags:\n" ++ + " - docs\n" ++ + "---\n" ++ + "# Spec\n", + ), + 3 => try allocator.dupe( + u8, + "# Spec\n\n" ++ + "\n", + ), + 4 => try allocator.dupe( + u8, + "---\n" ++ + "title: My Doc\n" ++ + "---\n\n" ++ + "\n\n" ++ + "Body.\n", + ), + else => try allocator.dupe( + u8, + "---\n" ++ + "drift:\n" ++ + " files:\n" ++ + " - src/existing.ts\n" ++ + "---\n\n" ++ + "See @./src/existing.ts in prose.\n", + ), + }; +} + +pub fn randomProvenance(allocator: std.mem.Allocator, random: std.Random) ![]const u8 { + const chars = "abcdef0123456789"; + + var buf: std.ArrayList(u8) = .{}; + defer buf.deinit(allocator); + + const len = random.intRangeAtMost(usize, 6, 12); + try appendRandomChars(&buf, allocator, random, chars, len); + return try allocator.dupe(u8, buf.items); +} + +pub fn anchorFilePath(anchor: []const u8) []const u8 { + const identity = frontmatter.anchorFileIdentity(anchor); + const hash_pos = std.mem.indexOfScalar(u8, identity, '#'); + return if (hash_pos) |pos| identity[0..pos] else identity; +} + +pub fn renderFrontmatterDoc(allocator: std.mem.Allocator, anchors: []const []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .{}; + defer out.deinit(allocator); + const writer = out.writer(allocator); + + try writer.writeAll("---\n"); + try writer.writeAll("drift:\n"); + try writer.writeAll(" files:\n"); + for (anchors) |anchor| { + try writer.print(" - {s}\n", .{anchor}); + } + try writer.writeAll("---\n# Spec\n"); + + return try allocator.dupe(u8, out.items); +} + +pub fn renderCommentDoc(allocator: std.mem.Allocator, anchors: []const []const u8) ![]const u8 { + var out: std.ArrayList(u8) = .{}; + defer out.deinit(allocator); + const writer = out.writer(allocator); + + try writer.writeAll("# Spec\n\n\n"); + + return try allocator.dupe(u8, out.items); +} + +pub fn expectAnchorPresent(anchors: []const []const u8, expected: []const u8) !void { + for (anchors) |anchor| { + if (std.mem.eql(u8, anchor, expected)) return; + } + std.debug.print("expected anchor missing: {s}\n", .{expected}); + return error.TestUnexpectedResult; +} + +pub fn expectAnchorAbsent(anchors: []const []const u8, unexpected: []const u8) !void { + for (anchors) |anchor| { + if (std.mem.eql(u8, anchor, unexpected)) { + std.debug.print("unexpected anchor present: {s}\n", .{unexpected}); + return error.TestUnexpectedResult; + } + } +} diff --git a/test/fuzz/property_test.zig b/test/fuzz/property_test.zig new file mode 100644 index 0000000..36aa870 --- /dev/null +++ b/test/fuzz/property_test.zig @@ -0,0 +1,236 @@ +const std = @import("std"); +const helper = @import("helpers.zig"); + +const frontmatter = helper.frontmatter; +const scanner = helper.scanner; + +test "property: link and unlink round-trip across random anchors and doc shapes" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(0x5eed5eed); + const random = prng.random(); + + for (0..250) |i| { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const anchor = try helper.randomAnchor(a, random); + const doc = try helper.baseDoc(a, i); + + const linked = try frontmatter.linkAnchor(a, doc, anchor); + var parsed = frontmatter.parseDriftSpec(a, linked) orelse return error.TestUnexpectedResult; + defer parsed.deinit(a); + try helper.expectAnchorPresent(parsed.items, anchor); + + const linked_again = try frontmatter.linkAnchor(a, linked, anchor); + try std.testing.expectEqualStrings(linked, linked_again); + + const unlinked = try frontmatter.unlinkAnchor(a, linked, anchor); + try std.testing.expect(unlinked.removed); + + if (frontmatter.parseDriftSpec(a, unlinked.content)) |anchors_after| { + var parsed_after = anchors_after; + defer parsed_after.deinit(a); + try helper.expectAnchorAbsent(parsed_after.items, anchor); + } + } +} + +test "property: inline ref parsing and rewriting preserve punctuation wrappers" { + const allocator = std.testing.allocator; + const wrappers = [_]helper.Wrapper{ + .{ .prefix = "\"", .suffix = "\"" }, + .{ .prefix = "'", .suffix = "'" }, + .{ .prefix = "(", .suffix = ")" }, + .{ .prefix = "[", .suffix = "]" }, + .{ .prefix = "<", .suffix = ">" }, + .{ .prefix = "", .suffix = "." }, + .{ .prefix = "", .suffix = "!" }, + .{ .prefix = "", .suffix = "?" }, + }; + + var prng = std.Random.DefaultPrng.init(0x1234abcd); + const random = prng.random(); + + for (wrappers) |wrapper| { + for (0..80) |_| { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const anchor = try helper.randomAnchor(a, random); + const content = try std.fmt.allocPrint( + a, + "# Spec\n\nSee {s}@./{s}{s} in the prose.\n", + .{ wrapper.prefix, anchor, wrapper.suffix }, + ); + + var anchors = scanner.parseInlineAnchors(a, content); + defer anchors.deinit(a); + try std.testing.expectEqual(@as(usize, 1), anchors.items.len); + try std.testing.expectEqualStrings(anchor, anchors.items[0]); + + const updated = try scanner.updateInlineAnchors(a, content, null, "seed1234"); + const expected = try std.fmt.allocPrint( + a, + "{s}@./{s}@seed1234{s}", + .{ wrapper.prefix, anchor, wrapper.suffix }, + ); + try std.testing.expect(std.mem.indexOf(u8, updated, expected) != null); + } + } +} + +test "property: inline ref parsing skips inline code and fenced code blocks" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(0xdecafbad); + const random = prng.random(); + + for (0..120) |_| { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const anchor = try helper.randomAnchor(a, random); + const content = try std.fmt.allocPrint( + a, + "# Spec\n\nSee @./{0s} in prose.\n\n`@./{0s}` should be ignored.\n\n```md\n@./{0s}\n```\n", + .{anchor}, + ); + + var anchors = scanner.parseInlineAnchors(a, content); + defer anchors.deinit(a); + try std.testing.expectEqual(@as(usize, 1), anchors.items.len); + try std.testing.expectEqualStrings(anchor, anchors.items[0]); + + const updated = try scanner.updateInlineAnchors(a, content, null, "seed5678"); + const prose_expected = try std.fmt.allocPrint(a, "@./{s}@seed5678 in prose", .{anchor}); + try std.testing.expect(std.mem.indexOf(u8, updated, prose_expected) != null); + + const inline_code_expected = try std.fmt.allocPrint(a, "`@./{s}`", .{anchor}); + try std.testing.expect(std.mem.indexOf(u8, updated, inline_code_expected) != null); + + const fenced_code_expected = try std.fmt.allocPrint(a, "```md\n@./{s}\n```", .{anchor}); + try std.testing.expect(std.mem.indexOf(u8, updated, fenced_code_expected) != null); + } +} + +test "property: relinkAllAnchors preserves anchor identities across storage formats" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(0x4242beef); + const random = prng.random(); + + for (0..180) |i| { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + var anchors: std.ArrayList([]const u8) = .{}; + defer anchors.deinit(a); + + const count = random.intRangeAtMost(usize, 1, 3); + while (anchors.items.len < count) { + const candidate = try helper.randomAnchor(a, random); + var duplicate = false; + for (anchors.items) |existing| { + if (std.mem.eql(u8, existing, candidate)) { + duplicate = true; + break; + } + } + if (duplicate) continue; + try anchors.append(a, candidate); + } + + const doc = if (i % 2 == 0) + try helper.renderFrontmatterDoc(a, anchors.items) + else + try helper.renderCommentDoc(a, anchors.items); + const provenance = try helper.randomProvenance(a, random); + + const relinked = try frontmatter.relinkAllAnchors(a, doc, provenance); + var parsed = frontmatter.parseDriftSpec(a, relinked) orelse return error.TestUnexpectedResult; + defer parsed.deinit(a); + + try std.testing.expectEqual(anchors.items.len, parsed.items.len); + for (anchors.items) |anchor| { + const expected = try std.fmt.allocPrint( + a, + "{s}@{s}", + .{ frontmatter.anchorFileIdentity(anchor), provenance }, + ); + try helper.expectAnchorPresent(parsed.items, expected); + } + } +} + +test "property: targeted inline updates only rewrite matching file refs" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(0xa11ce123); + const random = prng.random(); + + for (0..160) |_| { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const anchor = try helper.randomAnchor(a, random); + const target_identity = frontmatter.anchorFileIdentity(anchor); + const target_path = helper.anchorFilePath(anchor); + const old_provenance = try helper.randomProvenance(a, random); + + var other_anchor = try helper.randomAnchor(a, random); + while (std.mem.eql(u8, helper.anchorFilePath(other_anchor), target_path)) { + other_anchor = try helper.randomAnchor(a, random); + } + const other_identity = frontmatter.anchorFileIdentity(other_anchor); + + const content = try std.fmt.allocPrint( + a, + "# Spec\n\nSee @./{0s}, @./{0s}@{1s}, and @./{2s}.\n\n`@./{0s}` should stay literal.\n", + .{ target_identity, old_provenance, other_identity }, + ); + + const updated = try scanner.updateInlineAnchors(a, content, target_path, "newprov"); + + const target_expected = try std.fmt.allocPrint(a, "@./{s}@newprov", .{target_identity}); + try std.testing.expect(std.mem.indexOf(u8, updated, target_expected) != null); + + const old_target = try std.fmt.allocPrint(a, "@./{s}@{s}", .{ target_identity, old_provenance }); + try std.testing.expect(std.mem.indexOf(u8, updated, old_target) == null); + + const other_expected = try std.fmt.allocPrint(a, "@./{s}", .{other_identity}); + try std.testing.expect(std.mem.indexOf(u8, updated, other_expected) != null); + const other_unexpected = try std.fmt.allocPrint(a, "@./{s}@newprov", .{other_identity}); + try std.testing.expect(std.mem.indexOf(u8, updated, other_unexpected) == null); + + const inline_code_expected = try std.fmt.allocPrint(a, "`@./{s}`", .{target_identity}); + try std.testing.expect(std.mem.indexOf(u8, updated, inline_code_expected) != null); + } +} + +test "property: inline updates are idempotent with existing provenance" { + const allocator = std.testing.allocator; + + var prng = std.Random.DefaultPrng.init(0xbead1234); + const random = prng.random(); + + for (0..140) |_| { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + const a = arena.allocator(); + + const anchor = try helper.randomAnchor(a, random); + const identity = frontmatter.anchorFileIdentity(anchor); + const old_provenance = try helper.randomProvenance(a, random); + const content = try std.fmt.allocPrint(a, "# Spec\n\nSee @./{s}@{s}.\n", .{ identity, old_provenance }); + + const first = try scanner.updateInlineAnchors(a, content, null, "stableprov"); + const second = try scanner.updateInlineAnchors(a, first, null, "stableprov"); + try std.testing.expectEqualStrings(first, second); + } +}