From e3a7ba2fe0b3d143f5e5e070966bbcafa877366d Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 07:34:27 +0800 Subject: [PATCH 1/7] docs: design lazy mutation property tests --- ...-31-lazy-mutation-property-tests-design.md | 165 ++++++++++++++++++ 1 file changed, 165 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md diff --git a/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md b/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md new file mode 100644 index 0000000..a0ab89f --- /dev/null +++ b/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md @@ -0,0 +1,165 @@ +# Lazy Mutation Property Tests Design + +## Context + +Issue #104 asks for a focused hardening pass around `qjson.decode()` lazy table +proxies after reads, traversal, mutation, materialization, and serialization. +The runtime surface is Lua-side code in `lua/qjson/table.lua`; this work should +primarily add tests and CI coverage, not change production behavior unless the +new tests expose a real bug such as stale encoding, crashes, hangs, or an +undocumented ambiguous error. + +The existing suite already has deterministic encode property tests in +`tests/lua/encode_property_spec.lua` and many hand-written lazy/ordered encode +cases in `tests/lua/lazy_table_spec.lua` and `tests/lua/ordered_encode_spec.lua`. +The missing piece is an independent ordered oracle that can check mutation +sequences while preserving object key order. + +## Supported Boundary + +The property suite covers the public lazy table contract: + +- object field read, write, and delete through `t.k` +- array read, write, append, and hole creation through `t[i]` +- traversal through `qjson.pairs(t)` and `qjson.ipairs(t)` +- length through `qjson.len(t)` +- serialization through `qjson.encode(t)` +- full conversion through `qjson.materialize(t)` + +The suite documents these Lua primitives as out of scope because they bypass +the proxy contract: `rawset`, `rawget`, `next`, and direct metatable mutation. + +## Oracle Model + +Add a Lua test-only ordered model in `tests/lua/lazy_mutation_property_spec.lua`. +It uses explicit node tags instead of plain Lua tables: + +- `object`: ordered `entries = { { key, value }, ... }` +- `array`: ordered `items = { ... }`, with an explicit sparse-array policy for + holes created by assigning `nil` +- `null`: the qjson/cjson null sentinel +- scalar values: string, boolean, and number + +Object mutation follows the library's current ordered-encode contract: + +- unmodified duplicate keys can be preserved by the proxy fast path +- once a parent object is mutated or materialized for mutation, duplicate keys + collapse to first-appearance key order with last-wins values +- deleting and reinserting a key moves that key to the end + +The model has helper functions for read, write, delete, traversal, length, +materialization conversion, and JSON encoding. The encoding helper is +independent test code, not `qjson.encode`. + +## Equality + +Define `semantic_equal(model, value)` in the test file and document it next to +the implementation. + +The equality policy is: + +- key order is significant for ordered model vs ordered model comparisons and + for encoded JSON decoded back into the ordered model +- materialized Lua tables are compared structurally because plain Lua objects + cannot preserve key order; order-sensitive checks use the independent model + encoder/parser path +- `qjson.null` and `cjson.null` are equivalent null sentinels +- empty arrays are distinct from empty objects through `qjson.empty_array_mt` +- numbers are compared by numeric value after LuaJIT/cjson decoding; explicit + deterministic regressions cover large integers, `-0`, floats, and scientific + notation so encoder normalization cannot hide known-risk cases + +The checkpoint assertions are partly circular because two of them route through +qjson. The independent ordered model is the real oracle; qjson round trips are +used as extra consistency checks. + +## Stateful Generator + +Add deterministic randomized cases with environment controls: + +- `QJSON_MUT_PROP_CASES`, default small enough for `make test` +- `QJSON_MUT_PROP_SEED`, fixed default printed on failure +- `QJSON_MUT_PROP_STEPS`, fixed small default for CI + +The generator biases toward small readable trees and transition-heavy sequences +instead of uniform operation selection. It maintains handles to top-level and +nested nodes so the sequence can deliberately create hybrid trees containing +clean lazy proxies, cached children, mutated nodes, materialized outputs, and +plain replacement values. + +Operations include: + +- read a field or array element before mutation +- traverse an object or array before mutation +- mutate a top-level field +- mutate a nested field through a cached child proxy +- delete an object key +- add a new object key +- delete then reinsert the same key +- replace a container with a scalar +- replace a scalar with an object or array +- mutate and append array elements +- create an array hole, then check `qjson.len` and `qjson.ipairs` +- self-assignment with `t.k = t.k` +- assign one child proxy under a second key to pin alias semantics +- attempt a mutation-created cycle and assert a clean encode error +- mutate during `qjson.pairs` / `qjson.ipairs` iteration +- call `qjson.encode()` mid-sequence, then continue mutating +- call `qjson.materialize()` mid-sequence, then continue mutating + +Every checkpoint failure prints the seed, case number, source JSON, operation +trace, checkpoint name, model JSON, qjson encoded output when relevant, and the +materialized value when useful. A simple trace shrinker retries prefixes of the +operation trace and reports the shortest failing prefix when practical; if the +failure is not prefix-reproducible, it reports the full trace. + +## Deterministic Regression Cases + +Keep existing deterministic lazy and ordered encode cases, and add issue #104 +specific tests for: + +- unmodified proxies re-emitting original JSON bytes +- read child, mutate or materialize parent, then mutate the old child proxy +- nested child mutation marking ancestors dirty +- deleting or replacing a cached child container +- duplicate scalar and container keys, including mutated earlier duplicate +- escaped object keys and escaped string values +- null sentinel and empty array handling +- user keys named `_keys` and `_values` +- aliasing: assigning the same child proxy under two keys preserves shared Lua + identity semantics +- cycles: `qjson.encode` returns a bounded max-depth error rather than hanging + or crashing +- mutation during `qjson.pairs` and `qjson.ipairs` +- number fidelity for large integers, floats, `-0`, and scientific notation +- GC lifetime after cached child access and `collectgarbage()` +- idempotence of repeated `qjson.encode(lazy)` +- child `qjson.materialize` not polluting the source proxy + +## CI And Commands + +Extend the Makefile with a dedicated target for the lazy mutation property +suite while keeping `make test` deterministic: + +- `make test` runs the new suite through the existing `tests/lua` busted glob + with fixed defaults +- `make lua-mutation-property-test` runs just the new suite and accepts + `QJSON_MUT_PROP_*` overrides +- the existing `lua-property-test` target stays focused on encode/materialize + round-trip generation + +Add an on-demand and scheduled GitHub Actions stress workflow for the new suite. +It uses random or caller-supplied seeds and larger case/step counts, separate +from the PR-length CI gate. + +## Acceptance + +The work is complete when: + +- `tests/lua/lazy_mutation_property_spec.lua` exists and runs under busted +- the ordered oracle and `semantic_equal` policy are documented in the test file +- failures are reproducible from logged seed/source/trace/checkpoint details +- aliasing, cycles, mutation during iteration, number fidelity, GC lifetime, + idempotence, and materialize non-pollution are covered +- `make test`, `make lua-lint`, and the focused mutation property target pass +- a PR is opened against `api7/lua-qjson` referencing issue #104 From 20285ebdfb95be19676ac56b1c9ec49c6d31a567 Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 07:43:34 +0800 Subject: [PATCH 2/7] ci: add lazy mutation property stress target --- .../workflows/lua-lazy-mutation-property.yml | 124 ++++++++++++++++++ CONTRIBUTING.md | 18 +++ Makefile | 10 +- 3 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/lua-lazy-mutation-property.yml diff --git a/.github/workflows/lua-lazy-mutation-property.yml b/.github/workflows/lua-lazy-mutation-property.yml new file mode 100644 index 0000000..aad50db --- /dev/null +++ b/.github/workflows/lua-lazy-mutation-property.yml @@ -0,0 +1,124 @@ +name: Lua Lazy Mutation Property Stress + +on: + schedule: + - cron: "17 3 * * 0" + workflow_dispatch: + inputs: + cases: + description: "Override QJSON_MUT_PROP_CASES (default: 4000)" + required: false + steps: + description: "Override QJSON_MUT_PROP_STEPS (default: 200)" + required: false + seed: + description: "Override QJSON_MUT_PROP_SEED (random if empty)" + required: false + +env: + CARGO_TERM_COLOR: always + +jobs: + lua-mutation-property: + name: Lazy mutation property suite + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Install Rust (stable) + run: | + rustup toolchain install stable --profile minimal --no-self-update + rustup default stable + + - name: Cache cargo registry & target + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: mut-prop-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Makefile') }} + restore-keys: | + mut-prop-${{ runner.os }}- + + - name: Install LuaJIT + uses: leafo/gh-actions-lua@v13 + with: + luaVersion: "luajit-2.1.0-beta3" + + - name: Install LuaRocks + run: | + sudo apt-get update + sudo apt-get install -y lua5.1 liblua5.1-0-dev luarocks + + - name: Build cdylib (release) + run: cargo build --release + + - name: Install Lua dependencies + run: | + # Ubuntu LuaRocks targets Lua 5.1 by default; LuaJIT is ABI-compatible. + sudo luarocks install busted + sudo luarocks install lua-cjson + + - name: Resolve LuaJIT executable + id: luajit + run: | + set -euo pipefail + for candidate in luajit luajit-2.1.0-beta3 lua; do + if command -v "$candidate" >/dev/null 2>&1; then + lua_bin="$(command -v "$candidate")" + if "$lua_bin" -e 'assert(jit, "LuaJIT required")' >/dev/null 2>&1; then + echo "path=$lua_bin" >> "$GITHUB_OUTPUT" + "$lua_bin" -e 'print(jit.version)' + exit 0 + fi + fi + done + echo "LuaJIT executable not found" >&2 + exit 1 + + - name: Resolve stress parameters + id: params + env: + DISPATCH_CASES: ${{ github.event.inputs.cases }} + DISPATCH_STEPS: ${{ github.event.inputs.steps }} + DISPATCH_SEED: ${{ github.event.inputs.seed }} + run: | + set -euo pipefail + cases="${DISPATCH_CASES:-}" + steps="${DISPATCH_STEPS:-}" + seed="${DISPATCH_SEED:-}" + + if [ -z "$cases" ]; then + cases=4000 + fi + if [ -z "$steps" ]; then + steps=200 + fi + if [ -z "$seed" ]; then + seed="$(python3 - <<'PY' + import secrets + print(secrets.randbelow(2**31)) + PY + )" + fi + + echo "cases=$cases" >> "$GITHUB_OUTPUT" + echo "steps=$steps" >> "$GITHUB_OUTPUT" + echo "seed=$seed" >> "$GITHUB_OUTPUT" + + - name: Run lazy mutation property suite + env: + LUAJIT: ${{ steps.luajit.outputs.path }} + QJSON_MUT_PROP_CASES: ${{ steps.params.outputs.cases }} + QJSON_MUT_PROP_STEPS: ${{ steps.params.outputs.steps }} + QJSON_MUT_PROP_SEED: ${{ steps.params.outputs.seed }} + run: | + echo "QJSON_MUT_PROP_CASES=$QJSON_MUT_PROP_CASES" + echo "QJSON_MUT_PROP_STEPS=$QJSON_MUT_PROP_STEPS" + echo "QJSON_MUT_PROP_SEED=$QJSON_MUT_PROP_SEED" + make lua-mutation-property-test diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05008d1..b5d00ab 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -226,3 +226,21 @@ The property suite generates valid JSON containers, runs `decode -> materialize -> encode -> decode -> materialize`, checks structural equality, and probes the encoder max-depth boundary around 1000 nested containers. + +## Lua lazy mutation property tests + +Lazy mutation path coverage is in `tests/lua/lazy_mutation_property_spec.lua` +and uses deterministic defaults in the Makefile so PR runs are reproducible: + +```sh +make lua-mutation-property-test +``` + +Stress this focused suite locally by overriding case/step count and seed: + +```sh +make lua-mutation-property-test \ + QJSON_MUT_PROP_CASES=4000 \ + QJSON_MUT_PROP_STEPS=200 \ + QJSON_MUT_PROP_SEED=12345 +``` diff --git a/Makefile b/Makefile index 70ee089..c14b96d 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,9 @@ LUAJIT_PREFIX ?= $(shell dirname $$(dirname $$(command -v $(LUAJIT) 2>/dev/null LUAJIT_INC ?= $(LUAJIT_PREFIX)/include/luajit-2.1 QJSON_PROP_CASES ?= 200 QJSON_PROP_SEED ?= 760076 +QJSON_MUT_PROP_CASES ?= 200 +QJSON_MUT_PROP_SEED ?= 104104 +QJSON_MUT_PROP_STEPS ?= 24 LIB_DIR := $(CURDIR)/target/release ifeq ($(shell uname),Darwin) @@ -19,7 +22,7 @@ else LUA_ENV := LD_LIBRARY_PATH=$(LIB_DIR) LUA_PATH='$(LUA_PATH)' LUA_CPATH='$(LUA_CPATH)' endif -.PHONY: help build test lua-property-test lint lua-lint bench clean +.PHONY: help build test lua-property-test lua-mutation-property-test lint lua-lint bench clean help: ## Show this help @# FS uses [^#]* (not .*) so a description containing `##` isn't truncated. @@ -37,6 +40,11 @@ lua-property-test: build ## Run deterministic Lua encode/materialize property te QJSON_PROP_CASES=$(QJSON_PROP_CASES) QJSON_PROP_SEED=$(QJSON_PROP_SEED) \ $(LUA_ENV) busted --lua=$(LUAJIT) tests/lua/encode_property_spec.lua --lpath='./lua/?.lua' +lua-mutation-property-test: build ## Run deterministic Lua lazy-mutation property tests + QJSON_MUT_PROP_CASES=$(QJSON_MUT_PROP_CASES) QJSON_MUT_PROP_SEED=$(QJSON_MUT_PROP_SEED) \ + QJSON_MUT_PROP_STEPS=$(QJSON_MUT_PROP_STEPS) \ + $(LUA_ENV) busted --lua=$(LUAJIT) tests/lua/lazy_mutation_property_spec.lua --lpath='./lua/?.lua' + lint: ## Run clippy with -D warnings cargo clippy --release --all-targets -- -D warnings From 5f9b9d6b1dbc322326a7fd650b1b6593611d03cb Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 07:50:03 +0800 Subject: [PATCH 3/7] test: add lazy mutation property coverage --- Makefile | 2 +- tests/lua/lazy_mutation_property_spec.lua | 847 ++++++++++++++++++++++ 2 files changed, 848 insertions(+), 1 deletion(-) create mode 100644 tests/lua/lazy_mutation_property_spec.lua diff --git a/Makefile b/Makefile index c14b96d..552c0a3 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ OPENRESTY_RESTY := $(OPENRESTY)/bin/resty LUAJIT ?= $(shell if [ -x "$(OPENRESTY_LUAJIT)" ]; then echo "$(OPENRESTY_LUAJIT)"; else command -v luajit 2>/dev/null || echo luajit; fi) RESTY ?= $(shell if [ -x "$(OPENRESTY_RESTY)" ]; then echo "$(OPENRESTY_RESTY)"; else command -v resty 2>/dev/null || echo resty; fi) LUA_PATH ?= ./lua/?.lua;$(OPENRESTY)/lualib/?.lua;$(OPENRESTY)/lualib/?/init.lua;; -LUA_CPATH ?= ./vendor/lua-cjson/?.so;./target/release/lib?.so;./?.so;$(OPENRESTY)/lualib/?.so;/usr/local/lib/lua/5.1/?.so;$(OPENRESTY)/luajit/lib/lua/5.1/?.so +LUA_CPATH ?= ./vendor/lua-cjson/?.so;./target/release/lib?.dylib;./target/release/lib?.so;./?.so;$(OPENRESTY)/lualib/?.so;/usr/local/lib/lua/5.1/?.so;$(OPENRESTY)/luajit/lib/lua/5.1/?.so LUAJIT_PREFIX ?= $(shell dirname $$(dirname $$(command -v $(LUAJIT) 2>/dev/null || echo $(OPENRESTY_LUAJIT)))) LUAJIT_INC ?= $(LUAJIT_PREFIX)/include/luajit-2.1 diff --git a/tests/lua/lazy_mutation_property_spec.lua b/tests/lua/lazy_mutation_property_spec.lua new file mode 100644 index 0000000..51984b7 --- /dev/null +++ b/tests/lua/lazy_mutation_property_spec.lua @@ -0,0 +1,847 @@ +local qjson = require("qjson") +local cjson = require("cjson") +local prop = require("tests.lua.property_json") + +local CASES = tonumber(os.getenv("QJSON_MUT_PROP_CASES")) or 40 +local SEED = tonumber(os.getenv("QJSON_MUT_PROP_SEED")) or 104104 +local STEPS = tonumber(os.getenv("QJSON_MUT_PROP_STEPS")) or 24 + +-- Supported lazy proxy API under test: +-- t.k reads/writes/deletes, t[i] reads/writes/appends, qjson.pairs, +-- qjson.ipairs, qjson.len, qjson.encode, and qjson.materialize. +-- +-- Explicitly out of scope because they bypass the proxy contract: +-- rawset, rawget, next, and direct metatable replacement/mutation. + +local ABSENT = {} +local DELETE = {} + +local function cjson_decode_with_array_mt(json) + if not cjson.decode_array_with_array_mt then + return cjson.decode(json) + end + + local old = cjson.decode_array_with_array_mt() + cjson.decode_array_with_array_mt(true) + local ok, value = pcall(cjson.decode, json) + cjson.decode_array_with_array_mt(old) + if not ok then + error(value) + end + return value +end + +local function model_null() + return { kind = "null" } +end + +local function model_object(entries) + return { kind = "object", entries = entries or {} } +end + +local function model_array(items) + return { kind = "array", items = items or {} } +end + +local function entry(key, value) + return { key = key, value = value } +end + +local function is_model_node(v, kind) + return type(v) == "table" and v.kind == kind +end + +local function clone_model(v, seen) + if type(v) ~= "table" or not v.kind then + return v + end + seen = seen or {} + if seen[v] then + return seen[v] + end + if v.kind == "null" then + return model_null() + elseif v.kind == "array" then + local out = model_array({}) + seen[v] = out + for i, item in ipairs(v.items) do + out.items[i] = clone_model(item, seen) + end + return out + elseif v.kind == "object" then + local out = model_object({}) + seen[v] = out + for i, item in ipairs(v.entries) do + out.entries[i] = entry(item.key, clone_model(item.value, seen)) + end + return out + end + error("unknown model kind: " .. tostring(v.kind)) +end + +local function find_entry_index(obj, key) + for i, item in ipairs(obj.entries) do + if item.key == key then + return i + end + end + return nil +end + +local function model_get(obj, key) + local idx = find_entry_index(obj, key) + if not idx then + return ABSENT + end + return obj.entries[idx].value +end + +local function model_set(obj, key, value) + local idx = find_entry_index(obj, key) + if value == DELETE then + if idx then + table.remove(obj.entries, idx) + end + return + end + if idx then + obj.entries[idx].value = value + else + obj.entries[#obj.entries + 1] = entry(key, value) + end +end + +local function encode_string(s) + local out = { '"' } + for i = 1, #s do + local b = string.byte(s, i) + if b == 0x22 then + out[#out + 1] = '\\"' + elseif b == 0x5C then + out[#out + 1] = "\\\\" + elseif b == 0x0A then + out[#out + 1] = "\\n" + elseif b == 0x0D then + out[#out + 1] = "\\r" + elseif b == 0x09 then + out[#out + 1] = "\\t" + elseif b == 0x08 then + out[#out + 1] = "\\b" + elseif b == 0x0C then + out[#out + 1] = "\\f" + elseif b < 0x20 then + out[#out + 1] = string.format("\\u%04x", b) + else + out[#out + 1] = string.char(b) + end + end + out[#out + 1] = '"' + return table.concat(out) +end + +local function encode_number(n) + if n == math.floor(n) and math.abs(n) < 1e15 then + return string.format("%d", n) + end + return string.format("%.14g", n) +end + +local function model_encode(v, seen) + if type(v) ~= "table" or not v.kind then + local tv = type(v) + if tv == "string" then + return encode_string(v) + elseif tv == "number" then + return encode_number(v) + elseif tv == "boolean" then + return v and "true" or "false" + end + error("unsupported scalar model value: " .. tv) + end + + if v.kind == "null" then + return "null" + end + + seen = seen or {} + if seen[v] then + error("model_encode: cycle in oracle model") + end + seen[v] = true + + if v.kind == "array" then + local parts = {} + for i, item in ipairs(v.items) do + parts[i] = model_encode(item, seen) + end + seen[v] = nil + return "[" .. table.concat(parts, ",") .. "]" + end + + if v.kind == "object" then + local parts = {} + for i, item in ipairs(v.entries) do + parts[i] = encode_string(item.key) .. ":" .. model_encode(item.value, seen) + end + seen[v] = nil + return "{" .. table.concat(parts, ",") .. "}" + end + + error("unknown model kind: " .. tostring(v.kind)) +end + +local function model_to_lua(v, seen) + if type(v) ~= "table" or not v.kind then + return v + end + if v.kind == "null" then + return qjson.null + end + + seen = seen or {} + if seen[v] then + return seen[v] + end + + if v.kind == "array" then + local out = {} + seen[v] = out + for i, item in ipairs(v.items) do + out[i] = model_to_lua(item, seen) + end + if #out == 0 then + setmetatable(out, qjson.empty_array_mt) + end + return out + end + + if v.kind == "object" then + local out = {} + seen[v] = out + for _, item in ipairs(v.entries) do + out[item.key] = model_to_lua(item.value, seen) + end + return out + end + + error("unknown model kind: " .. tostring(v.kind)) +end + +local function model_to_assignable(v) + if is_model_node(v, "object") or is_model_node(v, "array") then + return qjson.decode(model_encode(v)) + end + return model_to_lua(v) +end + +local function is_json_null(v) + return rawequal(v, qjson.null) or rawequal(v, cjson.null) +end + +local function numbers_equal(a, b) + if a == b then + return true + end + local scale = math.max(1, math.abs(a), math.abs(b)) + return math.abs(a - b) <= scale * 1e-12 +end + +-- semantic_equal(model, value) is intentionally stricter than a plain table +-- deep-equal but less strict than byte-equality: +-- * qjson.null and cjson.null are equivalent JSON null sentinels. +-- * Empty arrays must carry an array metatable, so [] and {} stay distinct. +-- * Numbers compare by numeric value with a small floating tolerance. +-- * Object key order is part of the ordered model and is asserted through +-- model_encode(model) == qjson.encode(lazy). Plain Lua materialized objects +-- cannot preserve order, so this function checks their structure only. +local function semantic_equal(model, value, seen) + if is_model_node(model, "null") then + return is_json_null(value) + end + + local mt = type(model) == "table" and model.kind or nil + if mt == "array" then + if type(value) ~= "table" then + return false + end + if #model.items == 0 and getmetatable(value) == nil then + return false + end + if #value ~= #model.items then + return false + end + seen = seen or {} + if seen[model] == value then + return true + end + seen[model] = value + for i, item in ipairs(model.items) do + if not semantic_equal(item, value[i], seen) then + return false + end + end + return true + elseif mt == "object" then + if type(value) ~= "table" then + return false + end + seen = seen or {} + if seen[model] == value then + return true + end + seen[model] = value + + local expected = {} + for _, item in ipairs(model.entries) do + expected[item.key] = item.value + end + + local count = 0 + for k in pairs(value) do + count = count + 1 + if expected[k] == nil then + return false + end + end + if count ~= #model.entries then + return false + end + + for _, item in ipairs(model.entries) do + if not semantic_equal(item.value, value[item.key], seen) then + return false + end + end + return true + end + + if type(model) == "number" and type(value) == "number" then + return numbers_equal(model, value) + end + return model == value +end + +local function trace_text(trace, upto) + local parts = {} + upto = upto or #trace + for i = 1, upto do + parts[#parts + 1] = string.format("%02d %s", i, trace[i]) + end + return table.concat(parts, "\n") +end + +local function fail_message(ctx, checkpoint, trace, extra) + return table.concat({ + "checkpoint=" .. checkpoint, + "case=" .. ctx.case_no, + "seed=" .. ctx.seed, + "source=" .. ctx.source, + "trace:\n" .. trace_text(trace), + extra or "", + }, "\n") +end + +local function assert_model_value(ctx, label, expected, actual, trace) + local value = actual + if type(actual) == "table" then + local mt = getmetatable(actual) + if mt == qjson._LazyObject or mt == qjson._LazyArray then + value = qjson.materialize(actual) + end + end + assert.is_true( + semantic_equal(expected, value), + fail_message(ctx, label, trace, "value did not match model") + ) +end + +local function assert_checkpoint(ctx, name, lazy, model, trace) + local materialized = qjson.materialize(lazy) + assert.is_true( + semantic_equal(model, materialized), + fail_message(ctx, name, trace, "qjson.materialize(lazy) did not match model") + ) + + local encoded = qjson.encode(lazy) + local expected_json = model_encode(model) + assert.is_true( + encoded == expected_json, + fail_message(ctx, name, trace, "expected=" .. expected_json .. "\nencoded=" .. encoded) + ) + + local cjson_value = cjson_decode_with_array_mt(encoded) + assert.is_true( + semantic_equal(model, cjson_value), + fail_message(ctx, name, trace, "cjson.decode(qjson.encode(lazy)) did not match model") + ) + + local reparsed = qjson.materialize(qjson.decode(encoded)) + assert.is_true( + semantic_equal(model, reparsed), + fail_message(ctx, name, trace, "qjson.decode(qjson.encode(lazy)) did not match model") + ) +end + +local function random_string(rng) + local choices = { + "alpha", + "line\nbreak", + "quote\"value", + "slash\\value", + "tab\tvalue", + "_keys", + "_values", + } + return choices[rng:int(#choices)] .. tostring(rng:int(9)) +end + +local function random_scalar(rng) + local choice = rng:int(6) + if choice == 1 then + return model_null() + elseif choice == 2 then + return rng:bool() + elseif choice == 3 then + return rng:int(200) - 100 + elseif choice == 4 then + return (rng:int(200) - 100) / 4 + end + return random_string(rng) +end + +local function random_small_object(rng) + return model_object({ + entry("v", random_scalar(rng)), + }) +end + +local function random_small_array(rng) + local n = rng:int(3) - 1 + local items = {} + for i = 1, n do + items[i] = random_scalar(rng) + end + return model_array(items) +end + +local function initial_model(rng) + return model_object({ + entry("a", model_object({ + entry("x", rng:int(20)), + entry("y", random_string(rng)), + })), + entry("arr", model_array({ + rng:int(10), + rng:int(10), + rng:int(10), + })), + entry("flag", rng:bool()), + entry("txt", random_string(rng)), + }) +end + +local function append_trace(trace, text) + trace[#trace + 1] = text +end + +local function op_read_field(ctx) + local expected = model_get(ctx.model, "txt") + if expected == ABSENT then + append_trace(ctx.trace, "read txt (missing)") + assert.is_nil(ctx.lazy.txt) + return + end + append_trace(ctx.trace, "read txt") + assert_model_value(ctx, "read txt", expected, ctx.lazy.txt, ctx.trace) +end + +local function op_read_nested(ctx) + local a = model_get(ctx.model, "a") + if not is_model_node(a, "object") then + append_trace(ctx.trace, "read a.x skipped") + return + end + append_trace(ctx.trace, "read a.x") + local expected = model_get(a, "x") + if expected == ABSENT then + assert.is_nil(ctx.lazy.a.x) + return + end + assert_model_value(ctx, "read a.x", expected, ctx.lazy.a.x, ctx.trace) +end + +local function op_traverse_object(ctx) + append_trace(ctx.trace, "traverse root with qjson.pairs") + local i = 0 + for k, v in qjson.pairs(ctx.lazy) do + i = i + 1 + local expected = ctx.model.entries[i] + assert.is_truthy(expected, fail_message(ctx, "pairs root", ctx.trace, "too many keys")) + assert.are.equal(expected.key, k) + assert_model_value(ctx, "pairs root value", expected.value, v, ctx.trace) + end + assert.are.equal(#ctx.model.entries, i) +end + +local function op_traverse_array(ctx) + local arr = model_get(ctx.model, "arr") + if not is_model_node(arr, "array") then + append_trace(ctx.trace, "traverse arr skipped") + return + end + append_trace(ctx.trace, "traverse arr with qjson.ipairs") + local lazy_arr = ctx.lazy.arr + local i = 0 + for idx, value in qjson.ipairs(lazy_arr) do + i = i + 1 + assert.are.equal(i, idx) + assert_model_value(ctx, "ipairs arr value", arr.items[i], value, ctx.trace) + end + assert.are.equal(#arr.items, i) +end + +local function op_set_top_scalar(ctx) + local key = "p" .. tostring(ctx.rng:int(4)) + local value = random_scalar(ctx.rng) + append_trace(ctx.trace, "set " .. key .. " = " .. model_encode(value)) + ctx.lazy[key] = model_to_assignable(value) + model_set(ctx.model, key, value) +end + +local function op_mutate_nested(ctx) + local a = model_get(ctx.model, "a") + if not is_model_node(a, "object") then + append_trace(ctx.trace, "mutate a.x skipped") + return + end + local value = ctx.rng:int(2000) - 1000 + append_trace(ctx.trace, "mutate a.x = " .. tostring(value)) + ctx.lazy.a.x = value + model_set(a, "x", value) +end + +local function op_delete_and_reinsert(ctx) + local key = (ctx.rng:bool() and "flag") or "txt" + local value = random_scalar(ctx.rng) + append_trace(ctx.trace, "delete and reinsert " .. key) + ctx.lazy[key] = nil + model_set(ctx.model, key, DELETE) + ctx.lazy[key] = model_to_assignable(value) + model_set(ctx.model, key, value) +end + +local function op_delete_key(ctx) + local key = (ctx.rng:bool() and "flag") or "txt" + append_trace(ctx.trace, "delete " .. key) + ctx.lazy[key] = nil + model_set(ctx.model, key, DELETE) +end + +local function op_replace_container(ctx) + local value = random_scalar(ctx.rng) + append_trace(ctx.trace, "replace a = " .. model_encode(value)) + ctx.lazy.a = model_to_assignable(value) + model_set(ctx.model, "a", value) +end + +local function op_restore_container(ctx) + local value = random_small_object(ctx.rng) + append_trace(ctx.trace, "restore a = " .. model_encode(value)) + ctx.lazy.a = model_to_assignable(value) + model_set(ctx.model, "a", value) +end + +local function op_replace_scalar_with_object(ctx) + local value = random_small_object(ctx.rng) + append_trace(ctx.trace, "replace txt = " .. model_encode(value)) + ctx.lazy.txt = model_to_assignable(value) + model_set(ctx.model, "txt", value) +end + +local function op_append_array(ctx) + local arr = model_get(ctx.model, "arr") + if not is_model_node(arr, "array") then + append_trace(ctx.trace, "append arr skipped") + return + end + local value = random_scalar(ctx.rng) + append_trace(ctx.trace, "append arr[] = " .. model_encode(value)) + local lazy_arr = ctx.lazy.arr + lazy_arr[qjson.len(lazy_arr) + 1] = model_to_assignable(value) + arr.items[#arr.items + 1] = value +end + +local function op_mutate_array(ctx) + local arr = model_get(ctx.model, "arr") + if not is_model_node(arr, "array") or #arr.items == 0 then + append_trace(ctx.trace, "mutate arr[1] skipped") + return + end + local value = random_scalar(ctx.rng) + append_trace(ctx.trace, "mutate arr[1] = " .. model_encode(value)) + ctx.lazy.arr[1] = model_to_assignable(value) + arr.items[1] = value +end + +local function op_replace_array(ctx) + local value = random_small_array(ctx.rng) + append_trace(ctx.trace, "replace arr = " .. model_encode(value)) + ctx.lazy.arr = model_to_assignable(value) + model_set(ctx.model, "arr", value) +end + +local function op_self_assign(ctx) + local a = model_get(ctx.model, "a") + if a == ABSENT then + append_trace(ctx.trace, "self-assign a skipped") + return + end + append_trace(ctx.trace, "self-assign a = a") + ctx.lazy.a = ctx.lazy.a +end + +local function op_alias_child(ctx) + local a = model_get(ctx.model, "a") + if not is_model_node(a, "object") then + append_trace(ctx.trace, "alias a skipped") + return + end + local value = ctx.rng:int(5000) + append_trace(ctx.trace, "alias alias = a; alias.x = " .. tostring(value)) + ctx.lazy.alias = ctx.lazy.a + model_set(ctx.model, "alias", a) + ctx.lazy.alias.x = value + model_set(a, "x", value) +end + +local function op_encode_midsequence(ctx) + append_trace(ctx.trace, "encode mid-sequence") + assert_checkpoint(ctx, "mid-sequence encode", ctx.lazy, ctx.model, ctx.trace) +end + +local function op_materialize_midsequence(ctx) + append_trace(ctx.trace, "materialize mid-sequence") + local materialized = qjson.materialize(ctx.lazy) + assert.is_true( + semantic_equal(ctx.model, materialized), + fail_message(ctx, "mid-sequence materialize", ctx.trace, "materialized value mismatch") + ) +end + +local OPS = { + op_read_field, + op_read_nested, + op_traverse_object, + op_traverse_array, + op_set_top_scalar, + op_mutate_nested, + op_delete_and_reinsert, + op_delete_key, + op_replace_container, + op_restore_container, + op_replace_scalar_with_object, + op_append_array, + op_mutate_array, + op_replace_array, + op_self_assign, + op_alias_child, + op_encode_midsequence, + op_materialize_midsequence, +} + +local function execute_case(seed, case_no, steps) + local rng = prop.rng(seed + case_no * 7919) + local model = initial_model(rng) + local source = model_encode(model) + local ctx = { + seed = seed, + case_no = case_no, + source = source, + rng = rng, + model = clone_model(model), + lazy = qjson.decode(source), + trace = {}, + } + + assert_checkpoint(ctx, "initial", ctx.lazy, ctx.model, ctx.trace) + for _ = 1, steps do + OPS[rng:int(#OPS)](ctx) + assert_checkpoint(ctx, "after step", ctx.lazy, ctx.model, ctx.trace) + end +end + +local function shortest_failing_prefix(seed, case_no, steps) + for n = 1, steps do + local ok = pcall(execute_case, seed, case_no, n) + if not ok then + return n + end + end + return nil +end + +describe("qjson lazy mutation stateful property coverage", function() + it("keeps encode/materialize semantics aligned with an ordered model", function() + for case_no = 1, CASES do + local ok, err = pcall(execute_case, SEED, case_no, STEPS) + if not ok then + local prefix = shortest_failing_prefix(SEED, case_no, STEPS) + local shrink = prefix and ("shortest_failing_prefix=" .. prefix) + or "shortest_failing_prefix=not prefix-reproducible" + error(tostring(err) .. "\n" .. shrink, 0) + end + end + end) +end) + +describe("qjson lazy mutation deterministic regressions", function() + it("re-emits unmodified proxy bytes through the fast path", function() + local src = '{"z":3,"a":{"x":1},"a":{"x":2}}' + local t = qjson.decode(src) + assert.are.equal(src, qjson.encode(t)) + end) + + it("mutates an old child proxy after parent materialization", function() + local t = qjson.decode('{"a":{"x":1},"b":2}') + local old = t.a + t.c = 3 + old.x = 9 + assert.are.equal('{"a":{"x":9},"b":2,"c":3}', qjson.encode(t)) + end) + + it("marks ancestors dirty when a nested child changes", function() + local t = qjson.decode('{"a":{"b":{"c":1}},"d":2}') + t.a.b.c = 7 + assert.are.equal('{"a":{"b":{"c":7}},"d":2}', qjson.encode(t)) + end) + + it("handles duplicate keys with fast-path preservation and mutated last-wins collapse", function() + local src = '{"a":1,"a":2}' + assert.are.equal(src, qjson.encode(qjson.decode(src))) + + local t = qjson.decode(src) + t.b = 3 + assert.are.equal('{"a":2,"b":3}', qjson.encode(t)) + end) + + it("ignores earlier duplicate container mutations under last-wins materialization", function() + local t = qjson.decode('{"a":{"x":1},"a":{"y":2}}') + for _, v in qjson.pairs(t) do + if v.x then + v.x = 99 + end + end + assert.are.equal('{"a":{"y":2}}', qjson.encode(t)) + end) + + it("handles escaped object keys and escaped string values after mutation", function() + local t = qjson.decode('{"a\\nb":"x\\t\\"y","holder":{"k":"v"}}') + t.extra = true + assert.are.equal('{"a\\nb":"x\\t\\"y","holder":{"k":"v"},"extra":true}', qjson.encode(t)) + end) + + it("preserves nulls, empty arrays, and internal-name-like user keys", function() + local t = qjson.decode('{"_keys":[],"_values":{"x":null},"n":null}') + t.added = qjson.null + local m = qjson.materialize(t) + assert.are.equal(qjson.empty_array_mt, getmetatable(m._keys)) + assert.are.equal(qjson.null, m._values.x) + assert.are.equal(qjson.null, m.n) + assert.are.equal(qjson.null, m.added) + assert.are.equal('{"_keys":[],"_values":{"x":null},"n":null,"added":null}', qjson.encode(t)) + end) + + it("defines aliasing as shared lazy proxy identity", function() + local t = qjson.decode('{"a":{"x":1},"b":2}') + local a = t.a + t.alias = a + t.alias.x = 9 + assert.are.equal(t.a, t.alias) + assert.are.equal('{"a":{"x":9},"b":2,"alias":{"x":9}}', qjson.encode(t)) + end) + + it("returns a bounded encode error for mutation-created cycles", function() + local t = qjson.decode('{"a":{}}') + t.a.self = t + assert.has_error(function() + qjson.encode(t) + end, "qjson.encode: max depth exceeded") + end) + + it("documents sparse array hole semantics after lazy array mutation", function() + local t = qjson.decode('[1,2,3]') + t[2] = nil + + local seen = {} + for i, v in qjson.ipairs(t) do + seen[#seen + 1] = i .. "=" .. tostring(v) + end + + assert.are.equal(1, qjson.len(t)) + assert.are.same({"1=1"}, seen) + assert.are.equal("[1]", qjson.encode(t)) + end) + + it("supports mutation during object iteration with final ordered state", function() + local t = qjson.decode('{"a":1,"b":2}') + local seen = {} + for k, v in qjson.pairs(t) do + seen[#seen + 1] = k .. "=" .. tostring(v) + if k == "a" then + t.b = 20 + t.c = 3 + end + end + + assert.are.same({"a=1", "b=2"}, seen) + assert.are.equal('{"a":1,"b":20,"c":3}', qjson.encode(t)) + end) + + it("rejects mutation during lazy array iteration with a clean error", function() + local t = qjson.decode('[1,2,3]') + assert.has_error(function() + for i in qjson.ipairs(t) do + if i == 1 then + t[2] = 20 + end + end + end, "qjson: invalid argument") + end) + + it("forces number reserialization after a read without hiding normalization", function() + local t = qjson.decode('{"big":9007199254740993,"negzero":-0,"sci":1.25e3,"float":12.5}') + local _ = t.big + t.extra = 1 + assert.are.equal( + '{"big":9.007199254741e+15,"negzero":0,"sci":1250,"float":12.5,"extra":1}', + qjson.encode(t) + ) + end) + + it("keeps cached child proxies alive across GC", function() + local t = qjson.decode('{"a":{"x":1},"b":2}') + local a = t.a + collectgarbage() + collectgarbage() + a.x = 7 + assert.are.equal('{"a":{"x":7},"b":2}', qjson.encode(t)) + end) + + it("encodes lazy proxies idempotently", function() + local t = qjson.decode('{"a":{"x":1},"b":2}') + t.a.x = 5 + local once = qjson.encode(t) + local twice = qjson.encode(t) + assert.are.equal(once, twice) + end) + + it("does not pollute a source proxy when materializing a child", function() + local t = qjson.decode('{"a":{"x":1},"b":2}') + local child = t.a + local materialized = qjson.materialize(child) + materialized.x = 9 + + assert.are.equal(1, child.x) + assert.are.equal('{"a":{"x":1},"b":2}', qjson.encode(t)) + end) +end) From 66b370bd785fab2c116ae3642f222c2dc3e469cf Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 07:57:28 +0800 Subject: [PATCH 4/7] test: validate mutation property controls --- .../workflows/lua-lazy-mutation-property.yml | 15 ++++++++++++++- ...05-31-lazy-mutation-property-tests-design.md | 10 ++++++---- tests/lua/lazy_mutation_property_spec.lua | 17 ++++++++++++++--- 3 files changed, 34 insertions(+), 8 deletions(-) diff --git a/.github/workflows/lua-lazy-mutation-property.yml b/.github/workflows/lua-lazy-mutation-property.yml index aad50db..65a3fa9 100644 --- a/.github/workflows/lua-lazy-mutation-property.yml +++ b/.github/workflows/lua-lazy-mutation-property.yml @@ -102,11 +102,24 @@ jobs: if [ -z "$seed" ]; then seed="$(python3 - <<'PY' import secrets - print(secrets.randbelow(2**31)) + print(secrets.randbelow(2**31 - 1) + 1) PY )" fi + require_positive_int() { + name="$1" + value="$2" + if ! [[ "$value" =~ ^[1-9][0-9]*$ ]]; then + echo "$name must be a positive integer, got '$value'" >&2 + exit 1 + fi + } + + require_positive_int cases "$cases" + require_positive_int steps "$steps" + require_positive_int seed "$seed" + echo "cases=$cases" >> "$GITHUB_OUTPUT" echo "steps=$steps" >> "$GITHUB_OUTPUT" echo "seed=$seed" >> "$GITHUB_OUTPUT" diff --git a/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md b/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md index a0ab89f..e04b312 100644 --- a/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md +++ b/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md @@ -87,7 +87,7 @@ nested nodes so the sequence can deliberately create hybrid trees containing clean lazy proxies, cached children, mutated nodes, materialized outputs, and plain replacement values. -Operations include: +Randomized operations include: - read a field or array element before mutation - traverse an object or array before mutation @@ -99,14 +99,16 @@ Operations include: - replace a container with a scalar - replace a scalar with an object or array - mutate and append array elements -- create an array hole, then check `qjson.len` and `qjson.ipairs` - self-assignment with `t.k = t.k` - assign one child proxy under a second key to pin alias semantics -- attempt a mutation-created cycle and assert a clean encode error -- mutate during `qjson.pairs` / `qjson.ipairs` iteration - call `qjson.encode()` mid-sequence, then continue mutating - call `qjson.materialize()` mid-sequence, then continue mutating +High-risk operations that intentionally leave the normal stateful model, such +as mutation-created cycles, sparse array holes, and mutation during active +iteration, are pinned as deterministic regressions instead of randomized +operations so their currently documented semantics stay explicit. + Every checkpoint failure prints the seed, case number, source JSON, operation trace, checkpoint name, model JSON, qjson encoded output when relevant, and the materialized value when useful. A simple trace shrinker retries prefixes of the diff --git a/tests/lua/lazy_mutation_property_spec.lua b/tests/lua/lazy_mutation_property_spec.lua index 51984b7..77701c7 100644 --- a/tests/lua/lazy_mutation_property_spec.lua +++ b/tests/lua/lazy_mutation_property_spec.lua @@ -2,9 +2,20 @@ local qjson = require("qjson") local cjson = require("cjson") local prop = require("tests.lua.property_json") -local CASES = tonumber(os.getenv("QJSON_MUT_PROP_CASES")) or 40 -local SEED = tonumber(os.getenv("QJSON_MUT_PROP_SEED")) or 104104 -local STEPS = tonumber(os.getenv("QJSON_MUT_PROP_STEPS")) or 24 +local function positive_int_env(name, default) + local value = os.getenv(name) + if value == nil then + return default + end + if not value:match("^[1-9]%d*$") then + error(name .. " must be a positive integer, got " .. string.format("%q", value)) + end + return tonumber(value) +end + +local CASES = positive_int_env("QJSON_MUT_PROP_CASES", 40) +local SEED = positive_int_env("QJSON_MUT_PROP_SEED", 104104) +local STEPS = positive_int_env("QJSON_MUT_PROP_STEPS", 24) -- Supported lazy proxy API under test: -- t.k reads/writes/deletes, t[i] reads/writes/appends, qjson.pairs, From 0518beaf0faa521ce9261554a1314e2e0d00cdbe Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 10:16:37 +0800 Subject: [PATCH 5/7] docs: remove mutation property design note --- ...-31-lazy-mutation-property-tests-design.md | 167 ------------------ 1 file changed, 167 deletions(-) delete mode 100644 docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md diff --git a/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md b/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md deleted file mode 100644 index e04b312..0000000 --- a/docs/superpowers/specs/2026-05-31-lazy-mutation-property-tests-design.md +++ /dev/null @@ -1,167 +0,0 @@ -# Lazy Mutation Property Tests Design - -## Context - -Issue #104 asks for a focused hardening pass around `qjson.decode()` lazy table -proxies after reads, traversal, mutation, materialization, and serialization. -The runtime surface is Lua-side code in `lua/qjson/table.lua`; this work should -primarily add tests and CI coverage, not change production behavior unless the -new tests expose a real bug such as stale encoding, crashes, hangs, or an -undocumented ambiguous error. - -The existing suite already has deterministic encode property tests in -`tests/lua/encode_property_spec.lua` and many hand-written lazy/ordered encode -cases in `tests/lua/lazy_table_spec.lua` and `tests/lua/ordered_encode_spec.lua`. -The missing piece is an independent ordered oracle that can check mutation -sequences while preserving object key order. - -## Supported Boundary - -The property suite covers the public lazy table contract: - -- object field read, write, and delete through `t.k` -- array read, write, append, and hole creation through `t[i]` -- traversal through `qjson.pairs(t)` and `qjson.ipairs(t)` -- length through `qjson.len(t)` -- serialization through `qjson.encode(t)` -- full conversion through `qjson.materialize(t)` - -The suite documents these Lua primitives as out of scope because they bypass -the proxy contract: `rawset`, `rawget`, `next`, and direct metatable mutation. - -## Oracle Model - -Add a Lua test-only ordered model in `tests/lua/lazy_mutation_property_spec.lua`. -It uses explicit node tags instead of plain Lua tables: - -- `object`: ordered `entries = { { key, value }, ... }` -- `array`: ordered `items = { ... }`, with an explicit sparse-array policy for - holes created by assigning `nil` -- `null`: the qjson/cjson null sentinel -- scalar values: string, boolean, and number - -Object mutation follows the library's current ordered-encode contract: - -- unmodified duplicate keys can be preserved by the proxy fast path -- once a parent object is mutated or materialized for mutation, duplicate keys - collapse to first-appearance key order with last-wins values -- deleting and reinserting a key moves that key to the end - -The model has helper functions for read, write, delete, traversal, length, -materialization conversion, and JSON encoding. The encoding helper is -independent test code, not `qjson.encode`. - -## Equality - -Define `semantic_equal(model, value)` in the test file and document it next to -the implementation. - -The equality policy is: - -- key order is significant for ordered model vs ordered model comparisons and - for encoded JSON decoded back into the ordered model -- materialized Lua tables are compared structurally because plain Lua objects - cannot preserve key order; order-sensitive checks use the independent model - encoder/parser path -- `qjson.null` and `cjson.null` are equivalent null sentinels -- empty arrays are distinct from empty objects through `qjson.empty_array_mt` -- numbers are compared by numeric value after LuaJIT/cjson decoding; explicit - deterministic regressions cover large integers, `-0`, floats, and scientific - notation so encoder normalization cannot hide known-risk cases - -The checkpoint assertions are partly circular because two of them route through -qjson. The independent ordered model is the real oracle; qjson round trips are -used as extra consistency checks. - -## Stateful Generator - -Add deterministic randomized cases with environment controls: - -- `QJSON_MUT_PROP_CASES`, default small enough for `make test` -- `QJSON_MUT_PROP_SEED`, fixed default printed on failure -- `QJSON_MUT_PROP_STEPS`, fixed small default for CI - -The generator biases toward small readable trees and transition-heavy sequences -instead of uniform operation selection. It maintains handles to top-level and -nested nodes so the sequence can deliberately create hybrid trees containing -clean lazy proxies, cached children, mutated nodes, materialized outputs, and -plain replacement values. - -Randomized operations include: - -- read a field or array element before mutation -- traverse an object or array before mutation -- mutate a top-level field -- mutate a nested field through a cached child proxy -- delete an object key -- add a new object key -- delete then reinsert the same key -- replace a container with a scalar -- replace a scalar with an object or array -- mutate and append array elements -- self-assignment with `t.k = t.k` -- assign one child proxy under a second key to pin alias semantics -- call `qjson.encode()` mid-sequence, then continue mutating -- call `qjson.materialize()` mid-sequence, then continue mutating - -High-risk operations that intentionally leave the normal stateful model, such -as mutation-created cycles, sparse array holes, and mutation during active -iteration, are pinned as deterministic regressions instead of randomized -operations so their currently documented semantics stay explicit. - -Every checkpoint failure prints the seed, case number, source JSON, operation -trace, checkpoint name, model JSON, qjson encoded output when relevant, and the -materialized value when useful. A simple trace shrinker retries prefixes of the -operation trace and reports the shortest failing prefix when practical; if the -failure is not prefix-reproducible, it reports the full trace. - -## Deterministic Regression Cases - -Keep existing deterministic lazy and ordered encode cases, and add issue #104 -specific tests for: - -- unmodified proxies re-emitting original JSON bytes -- read child, mutate or materialize parent, then mutate the old child proxy -- nested child mutation marking ancestors dirty -- deleting or replacing a cached child container -- duplicate scalar and container keys, including mutated earlier duplicate -- escaped object keys and escaped string values -- null sentinel and empty array handling -- user keys named `_keys` and `_values` -- aliasing: assigning the same child proxy under two keys preserves shared Lua - identity semantics -- cycles: `qjson.encode` returns a bounded max-depth error rather than hanging - or crashing -- mutation during `qjson.pairs` and `qjson.ipairs` -- number fidelity for large integers, floats, `-0`, and scientific notation -- GC lifetime after cached child access and `collectgarbage()` -- idempotence of repeated `qjson.encode(lazy)` -- child `qjson.materialize` not polluting the source proxy - -## CI And Commands - -Extend the Makefile with a dedicated target for the lazy mutation property -suite while keeping `make test` deterministic: - -- `make test` runs the new suite through the existing `tests/lua` busted glob - with fixed defaults -- `make lua-mutation-property-test` runs just the new suite and accepts - `QJSON_MUT_PROP_*` overrides -- the existing `lua-property-test` target stays focused on encode/materialize - round-trip generation - -Add an on-demand and scheduled GitHub Actions stress workflow for the new suite. -It uses random or caller-supplied seeds and larger case/step counts, separate -from the PR-length CI gate. - -## Acceptance - -The work is complete when: - -- `tests/lua/lazy_mutation_property_spec.lua` exists and runs under busted -- the ordered oracle and `semantic_equal` policy are documented in the test file -- failures are reproducible from logged seed/source/trace/checkpoint details -- aliasing, cycles, mutation during iteration, number fidelity, GC lifetime, - idempotence, and materialize non-pollution are covered -- `make test`, `make lua-lint`, and the focused mutation property target pass -- a PR is opened against `api7/lua-qjson` referencing issue #104 From ac088e8ee19869c239fca43b3a16b70b1e22364a Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 10:21:01 +0800 Subject: [PATCH 6/7] test: address lazy mutation review feedback --- .../workflows/lua-lazy-mutation-property.yml | 9 +- tests/lua/lazy_mutation_property_spec.lua | 89 ++++++++++++++++--- 2 files changed, 84 insertions(+), 14 deletions(-) diff --git a/.github/workflows/lua-lazy-mutation-property.yml b/.github/workflows/lua-lazy-mutation-property.yml index 65a3fa9..0a46d31 100644 --- a/.github/workflows/lua-lazy-mutation-property.yml +++ b/.github/workflows/lua-lazy-mutation-property.yml @@ -25,9 +25,10 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: submodules: recursive + persist-credentials: false - name: Install Rust (stable) run: | @@ -35,18 +36,18 @@ jobs: rustup default stable - name: Cache cargo registry & target - uses: actions/cache@v4 + uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 with: path: | ~/.cargo/registry ~/.cargo/git target - key: mut-prop-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Makefile') }} + key: mut-prop-${{ runner.os }}-${{ hashFiles('Cargo.toml', 'Cargo.lock', 'Makefile') }} restore-keys: | mut-prop-${{ runner.os }}- - name: Install LuaJIT - uses: leafo/gh-actions-lua@v13 + uses: leafo/gh-actions-lua@6919171ccf181b826f44b9bca76307b577217377 with: luaVersion: "luajit-2.1.0-beta3" diff --git a/tests/lua/lazy_mutation_property_spec.lua b/tests/lua/lazy_mutation_property_spec.lua index 77701c7..fcc4cf0 100644 --- a/tests/lua/lazy_mutation_property_spec.lua +++ b/tests/lua/lazy_mutation_property_spec.lua @@ -352,6 +352,53 @@ local function fail_message(ctx, checkpoint, trace, extra) }, "\n") end +local function model_debug_json(value) + local ok, encoded = pcall(model_encode, value) + if ok then + return encoded + end + return "" +end + +local function actual_debug_json(value) + if value == nil then + return "nil" + end + + local debug_value = value + local note = nil + if type(value) == "table" then + local mt = getmetatable(value) + if mt == qjson._LazyObject or mt == qjson._LazyArray then + local ok, materialized = pcall(qjson.materialize, value) + if ok then + debug_value = materialized + else + note = "materialize_error=" .. tostring(materialized) + end + end + end + + local ok, encoded = pcall(qjson.encode, debug_value) + if ok then + if note then + return encoded .. "\n" .. note + end + return encoded + end + + if is_json_null(debug_value) then + return "null" + elseif type(debug_value) == "string" then + return encode_string(debug_value) + elseif type(debug_value) == "number" then + return encode_number(debug_value) + elseif type(debug_value) == "boolean" then + return debug_value and "true" or "false" + end + return tostring(debug_value) .. " (encode_error=" .. tostring(encoded) .. ")" +end + local function assert_model_value(ctx, label, expected, actual, trace) local value = actual if type(actual) == "table" then @@ -362,34 +409,54 @@ local function assert_model_value(ctx, label, expected, actual, trace) end assert.is_true( semantic_equal(expected, value), - fail_message(ctx, label, trace, "value did not match model") + fail_message( + ctx, + label, + trace, + "expected=" .. model_debug_json(expected) .. "\nactual=" .. actual_debug_json(actual) + ) ) end local function assert_checkpoint(ctx, name, lazy, model, trace) local materialized = qjson.materialize(lazy) + local expected_json = model_encode(model) assert.is_true( semantic_equal(model, materialized), - fail_message(ctx, name, trace, "qjson.materialize(lazy) did not match model") + fail_message( + ctx, + name, + trace, + "expected=" .. expected_json .. "\nactual_materialized=" .. actual_debug_json(materialized) + ) ) local encoded = qjson.encode(lazy) - local expected_json = model_encode(model) assert.is_true( encoded == expected_json, - fail_message(ctx, name, trace, "expected=" .. expected_json .. "\nencoded=" .. encoded) + fail_message(ctx, name, trace, "expected=" .. expected_json .. "\nactual_encoded=" .. encoded) ) local cjson_value = cjson_decode_with_array_mt(encoded) assert.is_true( semantic_equal(model, cjson_value), - fail_message(ctx, name, trace, "cjson.decode(qjson.encode(lazy)) did not match model") + fail_message( + ctx, + name, + trace, + "expected=" .. expected_json .. "\nactual_cjson_roundtrip=" .. actual_debug_json(cjson_value) + ) ) local reparsed = qjson.materialize(qjson.decode(encoded)) assert.is_true( semantic_equal(model, reparsed), - fail_message(ctx, name, trace, "qjson.decode(qjson.encode(lazy)) did not match model") + fail_message( + ctx, + name, + trace, + "expected=" .. expected_json .. "\nactual_qjson_roundtrip=" .. actual_debug_json(reparsed) + ) ) end @@ -683,9 +750,9 @@ end local function shortest_failing_prefix(seed, case_no, steps) for n = 1, steps do - local ok = pcall(execute_case, seed, case_no, n) + local ok, err = pcall(execute_case, seed, case_no, n) if not ok then - return n + return n, err end end return nil @@ -696,8 +763,10 @@ describe("qjson lazy mutation stateful property coverage", function() for case_no = 1, CASES do local ok, err = pcall(execute_case, SEED, case_no, STEPS) if not ok then - local prefix = shortest_failing_prefix(SEED, case_no, STEPS) - local shrink = prefix and ("shortest_failing_prefix=" .. prefix) + local prefix, prefix_err = shortest_failing_prefix(SEED, case_no, STEPS) + local shrink = prefix and ( + "shortest_failing_prefix=" .. prefix .. "\nminimized_error:\n" .. tostring(prefix_err) + ) or "shortest_failing_prefix=not prefix-reproducible" error(tostring(err) .. "\n" .. shrink, 0) end From 2fda9a65bd4cfe3e8b33b76232ba1ac85a1d5752 Mon Sep 17 00:00:00 2001 From: Yuansheng Wang Date: Sun, 31 May 2026 10:35:08 +0800 Subject: [PATCH 7/7] ci: use moving action tags in mutation workflow --- .github/workflows/lua-lazy-mutation-property.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/lua-lazy-mutation-property.yml b/.github/workflows/lua-lazy-mutation-property.yml index 0a46d31..90bb58e 100644 --- a/.github/workflows/lua-lazy-mutation-property.yml +++ b/.github/workflows/lua-lazy-mutation-property.yml @@ -25,7 +25,7 @@ jobs: permissions: contents: read steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + - uses: actions/checkout@v4 with: submodules: recursive persist-credentials: false @@ -36,7 +36,7 @@ jobs: rustup default stable - name: Cache cargo registry & target - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 + uses: actions/cache@v4 with: path: | ~/.cargo/registry @@ -47,7 +47,7 @@ jobs: mut-prop-${{ runner.os }}- - name: Install LuaJIT - uses: leafo/gh-actions-lua@6919171ccf181b826f44b9bca76307b577217377 + uses: leafo/gh-actions-lua@v13 with: luaVersion: "luajit-2.1.0-beta3"