diff --git a/Makefile b/Makefile index 21a7e888..1b4fc3c3 100644 --- a/Makefile +++ b/Makefile @@ -75,7 +75,7 @@ else ifeq ($(UNAME_S)-$(UNAME_M),Linux-aarch64) FORKPRESS_TARGET ?= aarch64-unknown-linux-musl endif -.PHONY: all clean test test-compat test-branchfs test-cow test-cow-fast test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-schema-review test-cow-stale-audit test-release init-db test-all forkpress forkpress-dev dist dist-dev +.PHONY: all clean test test-compat test-branchfs test-cow test-cow-fast test-cow-git-server test-cow-id-bands test-cow-media-validator test-cow-merge test-cow-merge-smoke test-cow-plugin-validator test-cow-schema-review test-cow-stale-audit test-release init-db test-all forkpress forkpress-dev dist dist-dev all: $(BRANCHFS_EXT_SO) @@ -119,6 +119,9 @@ test-cow-id-bands: test-cow-media-validator: php $(COW_TEST_DIR)/media_validator.php +test-cow-plugin-validator: + php $(COW_TEST_DIR)/plugin_validator.php + test-cow-stale-audit: php $(COW_TEST_DIR)/stale_audit.php @@ -128,6 +131,7 @@ test-cow-schema-review: test-cow-fast: test-cow-git-server test-cow-merge-smoke php $(COW_TEST_DIR)/id_bands.php php $(COW_TEST_DIR)/media_validator.php + php $(COW_TEST_DIR)/plugin_validator.php php $(COW_TEST_DIR)/schema_review.php php $(COW_TEST_DIR)/stale_audit.php php $(COW_TEST_DIR)/branch_ui.php diff --git a/docs/merge-reliability.md b/docs/merge-reliability.md index 560127e0..32444619 100644 --- a/docs/merge-reliability.md +++ b/docs/merge-reliability.md @@ -18,7 +18,7 @@ when there is a test or document that exercises the specific merge invariant. | Objective item | Evidence in this PR | Remaining gap | | --- | --- | --- | | 1. Real WordPress semantic merge coverage | `tests/cow/e2e.sh` creates source and target branches through runtime WordPress requests, validates each branch-local graph before merge, then merges pages, branch-local page edits/deletes with edited content and authors, postmeta, users/usermeta, authors, comments/commentmeta, hierarchical taxonomy terms, nav menus and menu locations, reusable `wp_block` rows, page-to-reusable-block refs, `core/image` block refs and featured-image refs to media attachments, options and JSON options with branch user/object IDs, media uploads with attachment parents plus generated-size metadata/files, a CPT-like `forkpress_note`, and plugin-shaped custom tables/files. The semantic E2E merge now requires `status: completed` and a zero-conflict merge run, so runtime-only state cannot hide behind a surviving object graph. `tests/cow/merge.php` adds deterministic WordPress row fingerprint and validator coverage. | Add broader concurrent edit/delete matrices for complete WP objects and deterministic repair policies only where the owner object is unambiguous. | -| 2. Plugin-specific merge semantics | `docs/plugin-merge-validators.md` defines the validator contract, including rejecting contradictory status/finding output. `scripts/cow/merge.php` discovers active plugin and mu-plugin validators, runs explicit validators, records plugin-scoped conflicts, and rolls back inline validator failures. `tests/cow/merge.php` covers clean custom-table graph merges, validator findings, audit/review grouping, validator rerun evidence, file-root context, active-plugin discovery, explicit-ID plugin graph validation, contradictory validator output rejection, and failed-validator rollback. `tests/cow/e2e.sh` covers a runtime plugin-shaped graph across custom table parent/child rows, child JSON payload refs, JSON, serialized data, options, postmeta, CPT data, and branch-owned file contents. | Add validators for real plugins and add merge drivers only for plugin-owned repairs that can prove correctness. | +| 2. Plugin-specific merge semantics | `docs/plugin-merge-validators.md` defines the validator contract, including rejecting contradictory status/finding output. `scripts/cow/merge.php` discovers active plugin and mu-plugin validators, runs explicit validators, records plugin-scoped conflicts, and rolls back inline validator failures. `tests/cow/plugin_validator.php` is a focused fast gate for discovered validator review of a plugin-owned DB/JSON/file graph, including plugin-scoped audit output for incoherent JSON and missing file references. `tests/cow/merge.php` covers clean custom-table graph merges, validator findings, audit/review grouping, validator rerun evidence, file-root context, active-plugin discovery, explicit-ID plugin graph validation, contradictory validator output rejection, and failed-validator rollback. `tests/cow/e2e.sh` covers a runtime plugin-shaped graph across custom table parent/child rows, child JSON payload refs, JSON, serialized data, options, postmeta, CPT data, and branch-owned file contents. | Add validators for real plugins and add merge drivers only for plugin-owned repairs that can prove correctness. | | 3. Remaining review-only schema cases | `scripts/cow/merge.php` validates source-added views/triggers, preserves invalid dependency cases as conflicts, and supports safe schema object resolution for deterministic subsets. `tests/cow/schema_review.php` is a focused fast gate proving cyclic source-added views/triggers stay reviewable, are not installed on the target, preserve the cycle reason, and do not record failed resolutions. `tests/cow/merge.php` covers broader cyclic/invalid view and trigger dependency handling, source-added dependent view ordering, and rebuild validation cases. | Improve dependency planning for more safe reorderings. Cyclic or semantically ambiguous cases should stay review-only. | | 4. Filesystem merge hardening | `tests/cow/merge.php` covers file adds/deletes/conflicts, binary hash comparisons, symlink safety, directory/file and file/directory replacement review, rollback artifacts, upload-file validators, generated attachment file checks, original/generated dimension drift, generated-size filename drift, featured-image/image-block/media metadata drift, and unsafe metadata paths. `tests/cow/e2e.sh` verifies real merged upload originals and generated thumbnails. | Add stricter uploads-specific validators for more drift shapes and explicit attachment-regeneration decisions. | | 5. Crash consistency across DB/files/metadata/Git | `docs/merge-crash-consistency.md` lists the covered boundaries. `tests/cow/merge.php` covers target DB, metadata, file, rollback-failure, ID-band, and whole-branch rollback paths. `tests/cow/e2e.sh` drives public merge/create/reset/recover crash/retry flows for DB, metadata, before-file, after-file, recovery-cleanup, branch-birth, branch-reset publication failpoints, and actual smart-HTTP Git-created branch pushes interrupted before branch-birth metadata, before branch-list publication, and after branch-list publication, each verified after a fresh server restart. `tests/cow/git_server.php` covers Git-created branch birth, Git update/delete, stale cleanup, and object-prune interruption. | Broaden external kill harness coverage across the remaining Git-push failpoints and platform-specific APFS/cleanup checkpoints, then verify post-crash state from a fresh process. | @@ -112,6 +112,12 @@ For WordPress upload/media validator changes, run: make test-cow-media-validator ``` +For plugin-owned DB/JSON/file graph validator changes, run: + +```bash +make test-cow-plugin-validator +``` + For stale-audit revalidation and guarded `--after-revalidate` resolution changes, run: diff --git a/tests/cow/plugin_validator.php b/tests/cow/plugin_validator.php new file mode 100644 index 00000000..b12a9329 --- /dev/null +++ b/tests/cow/plugin_validator.php @@ -0,0 +1,224 @@ +isDir() && !$entry->isLink() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($path); +} + +function copy_tree_for_test(string $source, string $dest): void { + mkdir($dest, 0777, true); + $it = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($source, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + foreach ($it as $entry) { + $target = $dest . '/' . str_replace(DIRECTORY_SEPARATOR, '/', substr($entry->getPathname(), strlen($source) + 1)); + if ($entry->isDir()) { + if (!is_dir($target)) { + mkdir($target, 0777, true); + } + continue; + } + if (!is_dir(dirname($target))) { + mkdir(dirname($target), 0777, true); + } + copy($entry->getPathname(), $target); + } +} + +function write_test_file(string $path, string $contents): void { + if (!is_dir(dirname($path))) { + mkdir(dirname($path), 0777, true); + } + file_put_contents($path, $contents); +} + +function open_db(string $path): SQLite3 { + $db = new SQLite3($path); + $db->busyTimeout(5000); + return $db; +} + +function scalar(string $db_path, string $sql): mixed { + $db = open_db($db_path); + $value = $db->querySingle($sql); + $db->close(); + return $value; +} + +function create_plugin_validator_db(string $path): void { + $db = open_db($path); + $db->exec('CREATE TABLE plugin_graph_parent (parent_id INTEGER PRIMARY KEY AUTOINCREMENT, label TEXT NOT NULL)'); + $db->exec('CREATE TABLE plugin_graph_child (child_id INTEGER PRIMARY KEY AUTOINCREMENT, parent_id INTEGER NOT NULL, graph_json TEXT NOT NULL, file_path TEXT NOT NULL)'); + $db->close(); +} + +define('FORKPRESS_COW_MERGE_TESTS', true); +require_once __DIR__ . '/../../scripts/cow/merge.php'; + +echo "=== COW plugin validator focused tests ===\n"; + +$tmp = sys_get_temp_dir() . '/forkpress-cow-plugin-validator-' . getmypid() . '-' . bin2hex(random_bytes(4)); +mkdir($tmp, 0777, true); + +try { + $base_root = $tmp . '/base'; + $source_root = $tmp . '/source'; + $target_root = $tmp . '/target'; + $base = $base_root . '/wp-content/database/.ht.sqlite'; + $source = $source_root . '/wp-content/database/.ht.sqlite'; + $target = $target_root . '/wp-content/database/.ht.sqlite'; + $metadata = $tmp . '/.forkpress/cow/merge/plugin-validator-metadata.sqlite'; + $file_base = $tmp . '/.forkpress/cow/merge/file-bases/plugin-validator.json'; + + mkdir($base_root . '/wp-content/database', 0777, true); + create_plugin_validator_db($base); + write_test_file($base_root . '/wp-content/mu-plugins/forkpress-merge-validator.php', <<<'PHP' +query('SELECT child_id, parent_id, graph_json, file_path FROM plugin_graph_child ORDER BY child_id'); +while ($row = $res->fetchArray(SQLITE3_ASSOC)) { + $child_id = (int)$row['child_id']; + $parent_id = (int)$row['parent_id']; + $graph = json_decode((string)$row['graph_json'], true); + if (!is_array($graph) || (int)($graph['child_id'] ?? 0) !== $child_id || (int)($graph['parent_id'] ?? 0) !== $parent_id) { + $findings[] = [ + 'plugin' => 'forkpress-plugin-graph', + 'object' => 'child:' . $child_id, + 'reason' => 'plugin child JSON graph does not match the merged row graph', + 'type' => 'plugin-graph-json-drift', + 'tables' => ['plugin_graph_child'], + 'validator' => 'forkpress-plugin-graph@1', + 'candidate' => [ + 'child_id' => $child_id, + 'parent_id' => $parent_id, + 'graph' => $graph, + ], + ]; + } + $parent_exists = (int)$db->querySingle('SELECT COUNT(*) FROM plugin_graph_parent WHERE parent_id = ' . $parent_id); + if ($parent_exists !== 1) { + $findings[] = [ + 'plugin' => 'forkpress-plugin-graph', + 'object' => 'child:' . $child_id, + 'reason' => 'plugin child references a missing parent row', + 'type' => 'plugin-graph-missing-parent', + 'tables' => ['plugin_graph_parent', 'plugin_graph_child'], + 'validator' => 'forkpress-plugin-graph@1', + 'candidate' => [ + 'child_id' => $child_id, + 'parent_id' => $parent_id, + ], + ]; + } + $file_path = str_replace('\\', '/', (string)$row['file_path']); + if ($file_path === '' || str_starts_with($file_path, '/') || str_contains($file_path, '..') || !is_file($target_root . '/' . $file_path)) { + $findings[] = [ + 'plugin' => 'forkpress-plugin-graph', + 'object' => 'child:' . $child_id, + 'reason' => 'plugin child references a missing or unsafe file', + 'type' => 'plugin-graph-file-drift', + 'tables' => ['plugin_graph_child'], + 'paths' => [$file_path], + 'validator' => 'forkpress-plugin-graph@1', + 'candidate' => [ + 'child_id' => $child_id, + 'file_path' => $file_path, + ], + ]; + } +} +echo json_encode([ + 'status' => $findings ? 'conflicts' : 'valid', + 'findings' => $findings, +], JSON_UNESCAPED_SLASHES); +PHP); + + copy_tree_for_test($base_root, $source_root); + copy_tree_for_test($base_root, $target_root); + cow_merge_capture_file_base($base_root, $file_base); + cow_merge_allocate_autoincrement_bands($source, $metadata, 'feature-plugin-validator-source'); + cow_merge_allocate_autoincrement_bands($target, $metadata, 'main'); + + $source_db = open_db($source); + $source_db->exec("INSERT INTO plugin_graph_parent (label) VALUES ('source plugin parent')"); + $parent_id = (int)$source_db->lastInsertRowID(); + $source_db->exec("INSERT INTO plugin_graph_child (parent_id, graph_json, file_path) VALUES ($parent_id, '{\"child_id\":9999,\"parent_id\":$parent_id}', 'wp-content/uploads/plugin-validator-missing.dat')"); + $child_id = (int)$source_db->lastInsertRowID(); + $source_db->close(); + + $result = cow_merge_branch_state( + $base, + $source, + $target, + $metadata, + 'feature-plugin-validator-source', + 'main', + $file_base, + $source_root, + $target_root + ); + + assert_same($result['status'], 'completed_with_conflicts', 'plugin validator holds incoherent plugin graph candidates for review'); + assert_same((int)($result['plugin_validators'] ?? 0), 1, 'plugin validator is discovered from mu-plugins during merge'); + assert_same((int)($result['plugin_validator_conflicts'] ?? 0), 2, 'plugin validator records JSON and file graph conflicts'); + assert_same( + scalar($target, "SELECT parent_id FROM plugin_graph_child WHERE child_id = $child_id"), + $parent_id, + 'plugin validator leaves the staged child row available for review' + ); + assert_true(!is_file($target_root . '/wp-content/uploads/plugin-validator-missing.dat'), 'plugin validator does not invent missing plugin files'); + + $audit = cow_merge_audit_report($metadata, (int)$result['run_id'], 10, [ + 'scope' => 'plugin', + 'records' => 'conflicts', + ]); + assert_same(count($audit['conflicts']), 2, 'plugin validator conflicts are visible in plugin audit scope'); + $preview = implode("\n", array_map(fn($conflict) => (string)($conflict['chosen_preview'] ?? ''), $audit['conflicts'])); + assert_true(str_contains($preview, 'plugin-validator-missing.dat'), 'plugin audit exposes missing plugin file context'); + assert_true(str_contains($preview, '"child_id":9999'), 'plugin audit exposes mismatched JSON graph context'); +} finally { + remove_tree($tmp); +} + +if ($fail) { + echo "FAILURES: $fail\n"; + exit(1); +} +echo "COW plugin validator focused tests passed ($pass assertions).\n";