-
Notifications
You must be signed in to change notification settings - Fork 7
test(backup): make survivor-copy retention invariant structural #824
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
e98205b
9bf75f1
692df53
3b6bff4
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| """Static CI guard for the JSONL backup retention invariant introduced in PR #815. | ||
|
|
||
| Behavior tests prove today's examples. This guard also pins the production call graph so a | ||
| future refactor cannot keep the fixtures green while bypassing the surviving-copy evidence. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import ast | ||
| import sys | ||
| from pathlib import Path | ||
|
|
||
| REFACTOR_GUIDANCE = ( | ||
| "PR #815 shipped an integrity check that could never execute while its tests passed. " | ||
| "If you refactored deliberately, UPDATE this guard; do not delete it." | ||
| ) | ||
|
|
||
|
|
||
| def _with_refactor_guidance(errors: list[str]) -> list[str]: | ||
| return [*errors, REFACTOR_GUIDANCE] if errors else errors | ||
|
|
||
|
|
||
| def _function(tree: ast.AST, name: str) -> ast.FunctionDef | None: | ||
| return next( | ||
| (node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == name), | ||
| None, | ||
| ) | ||
|
|
||
|
|
||
| def _call_name(call: ast.Call) -> str | None: | ||
| if isinstance(call.func, ast.Name): | ||
| return call.func.id | ||
| if isinstance(call.func, ast.Attribute): | ||
| return call.func.attr | ||
| return None | ||
|
|
||
|
|
||
| def _calls(function: ast.FunctionDef, name: str) -> list[ast.Call]: | ||
| return [node for node in ast.walk(function) if isinstance(node, ast.Call) and _call_name(node) == name] | ||
|
|
||
|
|
||
| def _has_compare(function: ast.FunctionDef, *, operator: type[ast.cmpop], terms: tuple[str, ...]) -> bool: | ||
| for node in ast.walk(function): | ||
| if not isinstance(node, ast.Compare) or not any(isinstance(op, operator) for op in node.ops): | ||
| continue | ||
| rendered = ast.unparse(node) | ||
| if all(term in rendered for term in terms): | ||
| return True | ||
| return False | ||
|
|
||
|
|
||
| def _passes_live_inventory(call: ast.Call) -> bool: | ||
| if any( | ||
| keyword.arg == "surviving_archives" | ||
| and isinstance(keyword.value, ast.Name) | ||
| and keyword.value.id == "surviving_archives" | ||
| for keyword in call.keywords | ||
| ): | ||
| return True | ||
| return len(call.args) >= 3 and isinstance(call.args[2], ast.Name) and call.args[2].id == "surviving_archives" | ||
|
|
||
|
|
||
| def _verified_upload_delete_lines(function: ast.FunctionDef) -> set[int]: | ||
| lines: set[int] = set() | ||
| for node in ast.walk(function): | ||
| if not isinstance(node, ast.If): | ||
| continue | ||
| condition = ast.unparse(node.test) | ||
| if 'result["verified"]' not in condition and "result['verified']" not in condition: | ||
| continue | ||
| if "upload" not in condition: | ||
| continue | ||
| lines.update(child.lineno for child in ast.walk(node) if isinstance(child, ast.Call)) | ||
| return lines | ||
|
|
||
|
|
||
| def inspect_jsonl_retention_invariant(source: str) -> list[str]: | ||
| """Return deterministic violations of the PR #815 surviving-copy contract.""" | ||
| try: | ||
| tree = ast.parse(source) | ||
| except SyntaxError as exc: | ||
| return [f"jsonl_backup.py is not valid Python: {exc}"] | ||
|
|
||
| errors: list[str] = [] | ||
| state_matches = _function(tree, "_state_matches") | ||
| select_candidates = _function(tree, "_select_backup_candidates") | ||
| update_state = _function(tree, "_update_state_for_uploaded") | ||
| run_backup = _function(tree, "run_backup") | ||
| required = { | ||
| "_state_matches": state_matches, | ||
| "_select_backup_candidates": select_candidates, | ||
| "_update_state_for_uploaded": update_state, | ||
| "run_backup": run_backup, | ||
| } | ||
| for name, function in required.items(): | ||
| if function is None: | ||
| errors.append(f"required retention function is missing: {name}") | ||
| if errors: | ||
| return _with_refactor_guidance(errors) | ||
|
|
||
| assert state_matches is not None | ||
| assert select_candidates is not None | ||
| assert update_state is not None | ||
| assert run_backup is not None | ||
|
|
||
| if not _has_compare( | ||
| state_matches, | ||
| operator=ast.NotIn, | ||
| terms=("archive_id", "surviving_archives"), | ||
| ): | ||
| errors.append("coverage must reject archive IDs absent from the live Drive inventory") | ||
| if not _has_compare( | ||
| state_matches, | ||
| operator=ast.NotEq, | ||
| terms=("live_md5", "recorded_md5"), | ||
| ): | ||
| errors.append("coverage must reject a surviving Drive object whose archived bytes changed") | ||
| if not _has_compare( | ||
| state_matches, | ||
| operator=ast.Eq, | ||
| terms=("recorded_hash", "_sha256_file(candidate.path)"), | ||
| ): | ||
| errors.append("coverage must compare the live source bytes with the archived source digest") | ||
|
|
||
| select_calls = _calls(select_candidates, "_state_matches") | ||
| if not select_calls or not any(_passes_live_inventory(call) for call in select_calls): | ||
| errors.append("candidate selection must pass the live Drive inventory into the coverage predicate") | ||
|
|
||
| list_calls = _calls(run_backup, "_list_surviving_archives") | ||
| selection_calls = _calls(run_backup, "_select_backup_candidates") | ||
| if ( | ||
| not list_calls | ||
| or not selection_calls | ||
| or min(call.lineno for call in list_calls) >= min(call.lineno for call in selection_calls) | ||
| ): | ||
| errors.append("run_backup must list surviving Drive objects before selecting covered files") | ||
| elif not any(_passes_live_inventory(call) for call in selection_calls): | ||
| errors.append("run_backup must hand its live Drive inventory to candidate selection") | ||
|
|
||
| state_write_calls = _calls(run_backup, "_update_state_for_uploaded") | ||
| required_state_keywords = {"archive_id", "archive_md5", "digests"} | ||
| if not state_write_calls or not any( | ||
| required_state_keywords <= {keyword.arg for keyword in call.keywords if keyword.arg} | ||
| for call in state_write_calls | ||
|
Comment on lines
+140
to
+144
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a refactor retains AGENTS.md reference: AGENTS.md:L33-L36 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in stacked #822. Commit — brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol |
||
| ): | ||
| errors.append("uploaded state must persist archive identity, archive bytes, and source-byte digests") | ||
|
|
||
| prune_calls = _calls(run_backup, "prune_drive_backups") | ||
| archive_unlinks = [ | ||
| call | ||
| for call in _calls(run_backup, "unlink") | ||
| if isinstance(call.func, ast.Attribute) | ||
| and isinstance(call.func.value, ast.Name) | ||
| and call.func.value.id == "archive_path" | ||
| ] | ||
| delete_calls = [*prune_calls, *archive_unlinks] | ||
| verified_lines = _verified_upload_delete_lines(run_backup) | ||
| if not prune_calls: | ||
| errors.append("the Drive retention deletion call disappeared instead of retaining its safety contract") | ||
| if not archive_unlinks: | ||
| errors.append("the local staging deletion call disappeared instead of retaining its safety contract") | ||
| if any(call.lineno not in verified_lines for call in delete_calls): | ||
| errors.append("backup deletion calls must remain inside verified-upload control flow") | ||
| if ( | ||
| state_write_calls | ||
| and delete_calls | ||
| and max(call.lineno for call in state_write_calls) >= min(call.lineno for call in delete_calls) | ||
| ): | ||
| errors.append("surviving-copy provenance must be persisted before any backup deletion call") | ||
|
|
||
| return _with_refactor_guidance(errors) | ||
|
|
||
|
|
||
| def main(argv: list[str] | None = None) -> int: | ||
| args = sys.argv[1:] if argv is None else argv | ||
| path = Path(args[0]) if args else Path("src/brainlayer/jsonl_backup.py") | ||
| errors = inspect_jsonl_retention_invariant(path.read_text(encoding="utf-8")) | ||
|
Comment on lines
+174
to
+177
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a deliberate refactor renames or deletes Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The requested case is covered: every structural mismatch emits the #815 incident and says — brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol |
||
| if errors: | ||
| for error in errors: | ||
| print(f"FAIL: {error}") | ||
| return 1 | ||
| print(f"PASS: PR #815 JSONL retention invariant is structurally intact in {path}") | ||
| return 0 | ||
|
|
||
|
|
||
| if __name__ == "__main__": | ||
| raise SystemExit(main()) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| from pathlib import Path | ||
|
|
||
|
|
||
| def test_jsonl_retention_guard_failure_tells_refactors_to_update_not_delete(tmp_path: Path, capsys) -> None: | ||
| from brainlayer.backup_retention_invariant import main | ||
|
|
||
| unsafe_path = tmp_path / "jsonl_backup.py" | ||
| unsafe_path.write_text("def run_backup():\n pass\n", encoding="utf-8") | ||
|
Comment on lines
+4
to
+8
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a changed-only push modifies only AGENTS.md reference: AGENTS.md:L225-L227 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in stacked #822 commit — brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol |
||
|
|
||
| assert main([str(unsafe_path)]) == 1 | ||
| output = capsys.readouterr().out | ||
| assert "UPDATE this guard; do not delete it" in output | ||
| assert "PR #815" in output | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,52 @@ def _write_jsonl(path: Path, line: str = '{"type":"message"}\n', *, mtime: float | |
| return path | ||
|
|
||
|
|
||
| def test_jsonl_retention_invariant_is_a_ci_guard_not_only_a_behavior_fixture(): | ||
| """Fail CI when #815's surviving-copy predicate or call-site ordering is loosened.""" | ||
| from brainlayer.backup_retention_invariant import inspect_jsonl_retention_invariant | ||
|
|
||
| source = Path("src/brainlayer/jsonl_backup.py").read_text(encoding="utf-8") | ||
|
|
||
| assert inspect_jsonl_retention_invariant(source) == [] | ||
|
|
||
| mutations = ( | ||
| ( | ||
| "archive_id not in surviving_archives", | ||
| "archive_id in surviving_archives", | ||
| "coverage must reject archive IDs absent from the live Drive inventory", | ||
| ), | ||
| ( | ||
| "live_md5 != recorded_md5", | ||
| "live_md5 == recorded_md5", | ||
| "coverage must reject a surviving Drive object whose archived bytes changed", | ||
| ), | ||
| ( | ||
| "recorded_hash == _sha256_file(candidate.path)", | ||
| "recorded_hash != _sha256_file(candidate.path)", | ||
| "coverage must compare the live source bytes with the archived source digest", | ||
| ), | ||
| ( | ||
| "surviving_archives=surviving_archives", | ||
| "surviving_archives=None", | ||
| "run_backup must hand its live Drive inventory to candidate selection", | ||
| ), | ||
| ( | ||
| "archive_id=file_id", | ||
| "missing_archive_id=file_id", | ||
| "uploaded state must persist archive identity, archive bytes, and source-byte digests", | ||
| ), | ||
| ( | ||
| 'if result["verified"] and upload:', | ||
| "if upload:", | ||
| "backup deletion calls must remain inside verified-upload control flow", | ||
| ), | ||
| ) | ||
|
Comment on lines
+53
to
+57
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The mutation list ends after testing one combined verified-upload condition and never independently removes or reorders the live-inventory listing, the selector-to-predicate inventory handoff, either deletion call, or persistence relative to each deletion. Those corresponding checker branches can therefore be deleted while this test's positive assertion and all six mutations remain green, defeating the intended structural protection even in a full-suite run. AGENTS.md reference: AGENTS.md:L33-L36 Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed in stacked #822. Commit — brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol |
||
| for original, weakened, expected_error in mutations: | ||
| assert original in source, f"mutation fixture drifted: {original}" | ||
| unsafe = source.replace(original, weakened, 1) | ||
| assert expected_error in inspect_jsonl_retention_invariant(unsafe) | ||
|
|
||
|
|
||
| def test_run_jsonl_backup_uploads_incremental_bundle_verifies_and_enqueues_summary(tmp_path, monkeypatch): | ||
| from brainlayer import jsonl_backup | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
When the deletion condition is inverted to
if not result["verified"] and not upload:, both required substrings are still present, so this helper marks the enclosed prune and unlink calls as verified-upload control flow and the checker returns clean. That permits deletion under exactly the unsafe conditions the guard is meant to reject; inspect the boolean expression for positive conjuncts rather than searching its rendered text.AGENTS.md reference: AGENTS.md:L33-L36
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed in stacked #822. Commit
2bcf4efftracks the direct_atomic_write_jsonpersistence call and exact positive deletion gate; later review-driven commits add independent mutations for ordering, both deletion calls, inventory flow, early success, producer starvation, and predicate neutralization. #824 is the core layer; #822 is the mandatory hardening layer and will be retargeted tomainbefore the preserved #824 branch is deleted.— brainlayerCodex-4c4386f6 (worker) · codex/gpt-5.6-sol