diff --git a/CLAUDE.md b/CLAUDE.md index 085628a..49887bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,7 @@ Reference: docs/DESIGN.md, docs/DECISIONS.md, docs/CLI.md, docs/RELEASING.md - File-is-the-struct pattern (Ghostty convention) - No `anyerror` in public APIs — explicit error sets - `zig fmt` enforced +- Property tests use minish with deterministic seed overrides - All tests use `std.testing.allocator` ## Code Patterns @@ -64,5 +65,6 @@ Markdown is a supported language with a two-grammar architecture: block grammar - `zig build test` runs all tests - Integration tests in `test/integration/` +- Property tests in `test/property/` use minish; pass `-Dminish-seed=` to reproduce a run - All tests use `std.testing.allocator` (auto leak detection) - Test fixtures per language in `test/fixtures/` diff --git a/build.zig b/build.zig index 2a6b9c9..74405ba 100644 --- a/build.zig +++ b/build.zig @@ -7,6 +7,7 @@ pub fn build(b: *std.Build) void { // Dependencies const clap_dep = b.dependency("clap", .{}); + const minish_dep = b.dependency("minish", .{}); // Build tree-sitter C library from vendor sources const ts_module = buildTreeSitter(b, target, optimize); @@ -80,6 +81,14 @@ pub fn build(b: *std.Build) void { const test_options = b.addOptions(); test_options.addOption([]const u8, "drift_bin", b.getInstallPath(.bin, "drift")); + // Property-test seed. Defaults to the git HEAD hash (first 16 hex chars as + // u64) so each commit explores a different slice of the state space; pass + // -Dminish-seed=N to reproduce a specific failing run. Null falls back to + // minish's timestamp-based seed. + const minish_seed_override = b.option(u64, "minish-seed", "Fixed seed for minish property tests"); + const minish_seed: ?u64 = minish_seed_override orelse gitHeadSeed(b); + test_options.addOption(?u64, "minish_seed", minish_seed); + // Helpers module for integration tests const helpers_module = b.createModule(.{ .root_source_file = b.path("test/helpers.zig"), @@ -99,6 +108,7 @@ pub fn build(b: *std.Build) void { .{ .name = "tree_sitter", .module = ts_module }, .{ .name = "helpers", .module = helpers_module }, .{ .name = "payload", .module = payload_module }, + .{ .name = "minish", .module = minish_dep.module("minish") }, }, }); linkGrammars(b, test_module); @@ -163,6 +173,22 @@ fn buildTreeSitter( return ts_zig_module; } +/// First 16 hex chars of the current git HEAD as a u64. Returns null in a +/// detached environment (no .git, no `git` on PATH, shallow clone w/ no HEAD). +/// Used to seed property tests per-commit so each commit walks a different path. +fn gitHeadSeed(b: *std.Build) ?u64 { + var code: u8 = undefined; + const stdout = b.runAllowFail( + &.{ "git", "rev-parse", "HEAD" }, + &code, + .ignore, + ) catch return null; + defer b.allocator.free(stdout); + const trimmed = std.mem.trim(u8, stdout, " \t\r\n"); + if (trimmed.len < 16) return null; + return std.fmt.parseInt(u64, trimmed[0..16], 16) catch null; +} + fn linkGrammars(b: *std.Build, module: *std.Build.Module) void { // Tree-sitter headers needed by grammar C sources module.addIncludePath(b.path("vendor/tree-sitter/lib/include")); diff --git a/build.zig.zon b/build.zig.zon index 37ac9c3..f38a302 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -55,5 +55,9 @@ .hash = "N-V-__8AAOphUwCl_jXY5BvJ_I-kB6cZuE48ZpMar9Gq2SiD", .lazy = true, }, + .minish = .{ + .url = "https://github.com/CogitatorTech/minish/archive/v0.3.0.tar.gz", + .hash = "minish-0.3.0-SQtSTYI3AgCxWdWbKxS_lvmfbp0wJk29ZW5C9CozJaxm", + }, }, } diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 653016f..0d94c83 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -178,7 +178,7 @@ Additional modules: ### lockfile.zig -Reads and writes `drift.lock`. The file is line-oriented: each non-blank, non-comment line is a binding in the format ` -> ...`. Parsing is two splits: `splitSequence(" -> ")` for the doc/rest boundary, then `splitScalar(' ')` for target and trailing key:value pairs. Writing sorts all lines lexically and appends a trailing newline. +Reads and writes `drift.lock`. The file is line-oriented: each non-blank, non-comment line is a binding in the format ` -> ...`. Parsing is two splits: `splitSequence(" -> ")` for the doc/rest boundary, then `splitScalar(' ')` for target and trailing key:value pairs. Writing canonicalizes each binding before output: metadata fields are sorted by key, then all rendered lines are sorted lexically and written with a trailing newline. Discovery: walks up from cwd checking for `drift.lock` at each directory. The lockfile's directory becomes the project root for resolving relative paths. @@ -221,6 +221,7 @@ Format rules: - One binding per line: ` -> ...` - Sorted lexically by full line content - Trailing key:value pairs for extensible metadata (`sig:`, `origin:`, future fields) +- Metadata fields are serialized in key order so semantically equivalent bindings produce identical bytes - Lines starting with `#` are comments, blank lines ignored - Discovery: walk up from cwd until `drift.lock` is found diff --git a/drift.lock b/drift.lock index cce053e..ff63a74 100644 --- a/drift.lock +++ b/drift.lock @@ -1,6 +1,6 @@ -.claude/skills/drift/SKILL.md -> src/main.zig sig:b6166454e541e8a4 origin:github:fiberplane/drift -.claude/skills/drift/SKILL.md -> src/vcs.zig sig:84da70be235ca9d4 origin:github:fiberplane/drift -CLAUDE.md -> build.zig sig:7194b38f39dbadba +.claude/skills/drift/SKILL.md -> src/main.zig origin:github:fiberplane/drift sig:b6166454e541e8a4 +.claude/skills/drift/SKILL.md -> src/vcs.zig origin:github:fiberplane/drift sig:84da70be235ca9d4 +CLAUDE.md -> build.zig sig:2dccb33f6b790afa CLAUDE.md -> src/main.zig sig:b6166454e541e8a4 docs/CLI.md -> src/commands/link.zig sig:7bd7f824afc30e0b docs/CLI.md -> src/commands/lint.zig sig:fe7bd5a687f3e917 @@ -8,7 +8,7 @@ docs/CLI.md -> src/commands/refs.zig sig:f623b7774086094e docs/CLI.md -> src/commands/status.zig sig:eade166d24a20b81 docs/CLI.md -> src/commands/unlink.zig sig:0dbe1ee3315211b5 docs/DESIGN.md -> src/context.zig sig:82d9da38ea486f36 -docs/DESIGN.md -> src/lockfile.zig sig:0e4b79237b6e4c8b +docs/DESIGN.md -> src/lockfile.zig sig:6fc5c159297244d1 docs/DESIGN.md -> src/main.zig sig:b6166454e541e8a4 docs/DESIGN.md -> src/symbols.zig sig:bbe250d43609daa1 docs/DESIGN.md -> src/vcs.zig sig:84da70be235ca9d4 diff --git a/src/lockfile.zig b/src/lockfile.zig index 45c9e91..6d6cf67 100644 --- a/src/lockfile.zig +++ b/src/lockfile.zig @@ -187,9 +187,21 @@ pub fn groupByDoc(allocator: std.mem.Allocator, bindings: []Binding) !std.ArrayL return docs; } -fn renderLineToWriter(writer: *std.Io.Writer, binding: Binding) !void { +fn lessThanMetadataByKey(_: void, a: MetadataField, b: MetadataField) bool { + return std.mem.order(u8, a.key, b.key) == .lt; +} + +/// Serializes one binding as ` -> [: ...]` with +/// metadata sorted by key so the on-disk form is a function of semantic state +/// only, not of `setField` insertion order. Uses `scratch` for a sort buffer. +fn renderLineToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, binding: Binding) !void { try writer.print("{s} -> {s}", .{ binding.doc_path, binding.target }); - for (binding.metadata.items) |field| { + if (binding.metadata.items.len == 0) return; + + const sorted = try scratch.dupe(MetadataField, binding.metadata.items); + defer scratch.free(sorted); + std.mem.sort(MetadataField, sorted, {}, lessThanMetadataByKey); + for (sorted) |field| { try writer.print(" {s}:{s}", .{ field.key, field.value }); } } @@ -205,7 +217,7 @@ pub fn serializeToWriter(scratch: std.mem.Allocator, writer: *std.Io.Writer, bin for (bindings) |binding| { var row: std.Io.Writer.Allocating = .init(scratch); errdefer row.deinit(); - try renderLineToWriter(&row.writer, binding); + try renderLineToWriter(scratch, &row.writer, binding); try lines.append(scratch, try row.toOwnedSlice()); } @@ -359,6 +371,71 @@ test "serialize sorts lines and appends trailing newline" { ); } +test "serialize emits metadata sorted by key regardless of insertion order" { + const allocator = std.testing.allocator; + + const mkBinding = struct { + fn f(alloc: std.mem.Allocator, field_pairs: []const [2][]const u8) !Binding { + var metadata: std.ArrayList(MetadataField) = .empty; + errdefer { + for (metadata.items) |field| { + alloc.free(field.key); + alloc.free(field.value); + } + metadata.deinit(alloc); + } + for (field_pairs) |pair| { + try metadata.append(alloc, .{ + .key = try alloc.dupe(u8, pair[0]), + .value = try alloc.dupe(u8, pair[1]), + }); + } + return .{ + .doc_path = try alloc.dupe(u8, "docs/x.md"), + .target = try alloc.dupe(u8, "src/x.ts"), + .metadata = metadata, + }; + } + }.f; + + const freeBinding = struct { + fn f(alloc: std.mem.Allocator, b: *Binding) void { + alloc.free(b.doc_path); + alloc.free(b.target); + for (b.metadata.items) |field| { + alloc.free(field.key); + alloc.free(field.value); + } + b.metadata.deinit(alloc); + } + }.f; + + var forward = [_]Binding{try mkBinding(allocator, &.{ + .{ "sig", "abc" }, + .{ "origin", "x" }, + .{ "lang", "ts" }, + })}; + defer freeBinding(allocator, &forward[0]); + + var reverse = [_]Binding{try mkBinding(allocator, &.{ + .{ "lang", "ts" }, + .{ "origin", "x" }, + .{ "sig", "abc" }, + })}; + defer freeBinding(allocator, &reverse[0]); + + const out_forward = try serialize(allocator, &forward); + defer allocator.free(out_forward); + const out_reverse = try serialize(allocator, &reverse); + defer allocator.free(out_reverse); + + try std.testing.expectEqualStrings(out_forward, out_reverse); + try std.testing.expectEqualStrings( + "docs/x.md -> src/x.ts lang:ts origin:x sig:abc\n", + out_forward, + ); +} + test "discover walks up to find drift.lock" { const allocator = std.testing.allocator; const io = std.testing.io; diff --git a/test/helpers.zig b/test/helpers.zig index f36a2b8..216004c 100644 --- a/test/helpers.zig +++ b/test/helpers.zig @@ -2,6 +2,11 @@ const std = @import("std"); const build_options = @import("build_options"); const payload = @import("payload"); +/// Seed for minish property tests. Defaults to the first 16 hex chars of the +/// current git HEAD so each commit explores a fresh slice of the state space. +/// Null falls back to minish's timestamp seed. Override with `-Dminish-seed=N`. +pub const minish_seed: ?u64 = build_options.minish_seed; + /// Result returned by runDrift. pub const ExecResult = struct { stdout: []const u8, diff --git a/test/property/lockfile_reorder_test.zig b/test/property/lockfile_reorder_test.zig new file mode 100644 index 0000000..2a61efb --- /dev/null +++ b/test/property/lockfile_reorder_test.zig @@ -0,0 +1,205 @@ +//! Property 1: semantic_eq(L1, L2) ⟹ serialize(L1) == serialize(L2) +//! +//! Two lockfiles whose bindings and metadata form the same multiset must +//! serialize to the same bytes, regardless of iteration order. This catches +//! the class of spurious merge conflicts where two branches converged on the +//! same semantic state through different `setField` sequences. + +const std = @import("std"); +const minish = @import("minish"); +const lockfile = @import("../../src/lockfile.zig"); +const helpers = @import("helpers"); + +const GenError = minish.GenError; +const TestCase = minish.TestCase; + +/// Semantic description of a lockfile: a flat list of entries with no +/// assumption of order. Keys within each entry are unique by construction +/// (generator guarantees) so `semantic_eq` reduces to multiset equality. +const RawField = struct { + key: []const u8, + value: []const u8, +}; + +const RawEntry = struct { + doc_path: []const u8, + target: []const u8, + fields: []const RawField, +}; + +const State = []const RawEntry; + +/// Fixed key pool used by the generator. A small, fixed pool makes the +/// uniqueness guarantee cheap (subset of a known set) and keeps counterexamples +/// readable. +const KEY_POOL = [_][]const u8{ "sig", "origin", "lang", "ver", "ref", "hash" }; +const MAX_ENTRIES = 6; + +fn genShortString(tc: *TestCase, comptime prefix: []const u8) GenError![]const u8 { + // 1–2 trailing chars from a small alphabet keeps paths short and + // counterexamples easy to read; collisions between entries are intentional + // (two bindings to the same (doc, target) is a real case). + const CHARS = "abcd"; + const len = 1 + try tc.choice(1); // 1..2 + var buf = try tc.allocator.alloc(u8, prefix.len + len); + errdefer tc.allocator.free(buf); + @memcpy(buf[0..prefix.len], prefix); + for (0..len) |i| { + const idx = try tc.choice(CHARS.len - 1); + buf[prefix.len + i] = CHARS[idx]; + } + return buf; +} + +fn genValue(tc: *TestCase) GenError![]const u8 { + const CHARS = "0123456789abcdef"; + const len = 1 + try tc.choice(3); // 1..4 + var buf = try tc.allocator.alloc(u8, len); + errdefer tc.allocator.free(buf); + for (0..len) |i| { + const idx = try tc.choice(CHARS.len - 1); + buf[i] = CHARS[idx]; + } + return buf; +} + +fn genFields(tc: *TestCase) GenError![]const RawField { + // Pick a random subset of KEY_POOL via Fisher–Yates on indices, then take + // the first `num` elements. Guarantees unique keys per entry without any + // post-hoc dedup logic. + var indices: [KEY_POOL.len]usize = undefined; + for (0..KEY_POOL.len) |i| indices[i] = i; + var i: usize = KEY_POOL.len; + while (i > 1) { + i -= 1; + const j = try tc.choice(@intCast(i)); + std.mem.swap(usize, &indices[i], &indices[j]); + } + + const num = try tc.choice(KEY_POOL.len); // 0..KEY_POOL.len + var fields = try tc.allocator.alloc(RawField, num); + errdefer { + for (fields) |f| { + tc.allocator.free(f.key); + tc.allocator.free(f.value); + } + tc.allocator.free(fields); + } + for (0..num) |f_idx| { + const key = try tc.allocator.dupe(u8, KEY_POOL[indices[f_idx]]); + errdefer tc.allocator.free(key); + const value = try genValue(tc); + fields[f_idx] = .{ .key = key, .value = value }; + } + return fields; +} + +fn freeFields(allocator: std.mem.Allocator, fields: []const RawField) void { + for (fields) |f| { + allocator.free(f.key); + allocator.free(f.value); + } + allocator.free(fields); +} + +fn genEntry(tc: *TestCase) GenError!RawEntry { + const doc_path = try genShortString(tc, "doc_"); + errdefer tc.allocator.free(doc_path); + const target = try genShortString(tc, "src_"); + errdefer tc.allocator.free(target); + const fields = try genFields(tc); + return .{ .doc_path = doc_path, .target = target, .fields = fields }; +} + +fn freeEntry(allocator: std.mem.Allocator, entry: RawEntry) void { + allocator.free(entry.doc_path); + allocator.free(entry.target); + freeFields(allocator, entry.fields); +} + +fn generateState(tc: *TestCase) GenError!State { + const num = try tc.choice(MAX_ENTRIES); // 0..MAX_ENTRIES + var entries = try tc.allocator.alloc(RawEntry, num); + errdefer { + for (entries) |e| freeEntry(tc.allocator, e); + tc.allocator.free(entries); + } + for (0..num) |i| entries[i] = try genEntry(tc); + return entries; +} + +fn freeState(allocator: std.mem.Allocator, state: State) void { + for (state) |e| freeEntry(allocator, e); + allocator.free(state); +} + +const state_gen: minish.gen.Generator(State) = .{ + .generateFn = generateState, + .shrinkFn = null, + .freeFn = freeState, +}; + +/// Build a `[]lockfile.Binding` from the raw state. Allocations come from the +/// caller-provided arena so the property function can free everything at once. +/// `reverse` swaps both binding order and within-binding field order — the +/// simplest permutation that, combined with a forward-order build, exercises +/// any order-sensitivity in serialization. +fn buildBindings( + arena: std.mem.Allocator, + state: State, + reverse: bool, +) !std.ArrayList(lockfile.Binding) { + var bindings: std.ArrayList(lockfile.Binding) = .empty; + const n = state.len; + for (0..n) |i| { + const e_idx = if (reverse) n - 1 - i else i; + const entry = state[e_idx]; + + var metadata: std.ArrayList(lockfile.MetadataField) = .empty; + const m = entry.fields.len; + for (0..m) |j| { + const f_idx = if (reverse) m - 1 - j else j; + try metadata.append(arena, .{ + .key = try arena.dupe(u8, entry.fields[f_idx].key), + .value = try arena.dupe(u8, entry.fields[f_idx].value), + }); + } + + try bindings.append(arena, .{ + .doc_path = try arena.dupe(u8, entry.doc_path), + .target = try arena.dupe(u8, entry.target), + .metadata = metadata, + }); + } + return bindings; +} + +fn canonicalSerializationProperty(state: State) !void { + var arena_l1: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_l1.deinit(); + var arena_l2: std.heap.ArenaAllocator = .init(std.testing.allocator); + defer arena_l2.deinit(); + + const l1 = try buildBindings(arena_l1.allocator(), state, false); + const l2 = try buildBindings(arena_l2.allocator(), state, true); + + const s1 = try lockfile.serialize(arena_l1.allocator(), l1.items); + const s2 = try lockfile.serialize(arena_l2.allocator(), l2.items); + + std.testing.expectEqualStrings(s1, s2) catch |err| { + std.debug.print( + "\n--- forward serialization ---\n{s}\n--- reverse serialization ---\n{s}\n", + .{ s1, s2 }, + ); + return err; + }; +} + +test "property: serialize is invariant under binding+field reorder" { + try minish.check( + std.testing.allocator, + state_gen, + canonicalSerializationProperty, + .{ .num_runs = 200, .seed = helpers.minish_seed }, + ); +} diff --git a/test/property/smoke_test.zig b/test/property/smoke_test.zig new file mode 100644 index 0000000..a8533ce --- /dev/null +++ b/test/property/smoke_test.zig @@ -0,0 +1,21 @@ +//! Smoke test for the minish property-testing wiring. +//! If this file fails to compile or the test fails, the minish import is broken. + +const std = @import("std"); +const minish = @import("minish"); +const helpers = @import("helpers"); + +test "minish: trivial int property (a + 0 == a)" { + const identity = struct { + fn prop(x: i32) !void { + try std.testing.expectEqual(x, x + 0); + } + }.prop; + + try minish.check( + std.testing.allocator, + minish.gen.intRange(i32, -1000, 1000), + identity, + .{ .num_runs = 25, .seed = helpers.minish_seed }, + ); +} diff --git a/tests.zig b/tests.zig index f383c45..73cb030 100644 --- a/tests.zig +++ b/tests.zig @@ -9,4 +9,8 @@ test { _ = @import("test/integration/link_test.zig"); _ = @import("test/integration/unlink_test.zig"); _ = @import("test/integration/refs_test.zig"); + + // Property tests + _ = @import("test/property/smoke_test.zig"); + _ = @import("test/property/lockfile_reorder_test.zig"); }