diff --git a/.work/todo.jsonl b/.work/todo.jsonl index 87bf901..2b03b09 100644 --- a/.work/todo.jsonl +++ b/.work/todo.jsonl @@ -94,3 +94,15 @@ {"actor":"grok-build/wiki-publish","ev":"01M19SADGD9V8449YW7JZW4HW6","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"create","set":{"body":"Worklog-spec step 8: publish the GitHub wiki from docs/.index/publish-manifest.json after v0.9.0 and marketplace 0.4.4. Recast retrieval-ladder as a living guide so it does not collide with Code-Walkthrough.","kind":"ops","level":"story","priority":"P2","status":"todo","title":"Publish v0.9.0 wiki pages"},"ts":"2026-08-30T16:51:22Z"} {"actor":"grok-build/wiki-publish","ev":"01M19SBCRR96E9536RS3WZ35DH","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"update","set":{"status":"in_progress"},"ts":"2026-08-30T16:51:54Z"} {"actor":"grok-build/wiki-publish","ev":"01M19SD8CKVHA64PQG035Q3NQ5","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"close","set":{"resolution":"wiki published: https://github.com/SpillwaveSolutions/project-knowledge-capture/wiki/Retrieval-ladder (wiki rev ce98663); catalog 0.4.4 already pinned","status":"done"},"ts":"2026-08-30T16:52:55Z"} +{"actor":"richardhightower","ev":"01M1CEEP1E9HC22TFTDKNBAVM4","item":"01M1CEEP1EG2793CSYT4AJC8K5","op":"create","set":{"body":"Make materialization previews strictly side-effect-free while preserving clear planned-action output and regression coverage.","kind":"feature","level":"epic","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P1","status":"todo","title":"Fix issue #69 dry-run writes"},"ts":"2026-08-31T17:39:11Z"} +{"actor":"richardhightower","ev":"01M1CEEP1EAXF85TTCVQ89NWW4","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"create","set":{"body":"Confirm concept, catalog, log, and write-event behavior before changing implementation.","kind":"feature","level":"task","parent":"01M1CEEP1EG2793CSYT4AJC8K5","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P2","status":"todo","title":"Reproduce dry-run filesystem mutations"},"ts":"2026-08-31T17:39:11Z"} +{"actor":"richardhightower","ev":"01M1CEEP1EGEGT6W7J4AKEKB7W","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"create","set":{"body":"Report planned actions without creating, updating, or deleting bundle content.","kind":"feature","level":"task","parent":"01M1CEEP1EG2793CSYT4AJC8K5","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P2","status":"todo","title":"Implement side-effect-free dry-run behavior"},"ts":"2026-08-31T17:39:11Z"} +{"actor":"richardhightower","ev":"01M1CEEP1E7ZQJ5Y7D7BZGG8GT","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"create","set":{"body":"Snapshot the target tree and verify all dry-run output modes leave it unchanged.","kind":"feature","level":"task","parent":"01M1CEEP1EG2793CSYT4AJC8K5","plan":"docs/plans/2026-08-31-fix-issue-69-dry-run.md","priority":"P2","status":"todo","title":"Add and run regression coverage"},"ts":"2026-08-31T17:39:11Z"} +{"actor":"richardhightower","ev":"01M1CEFN30X99S04ZEJJVMHFYM","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:39:43Z"} +{"actor":"richardhightower","ev":"01M1CF0544EZ9W1M17ZNJRKQT8","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"} +{"actor":"richardhightower","ev":"01M1CF058FCJMMPWB7DTVFW7B8","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"} +{"actor":"richardhightower","ev":"01M1CF05DTWPCA4XJKQM3BBQFZ","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"} +{"actor":"richardhightower","ev":"01M1CF05M57TJM9X3XVZWJW0VP","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"} +{"actor":"richardhightower","ev":"01M1CF05SSATV2H6HDDYGTPGQP","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"} +{"actor":"richardhightower","ev":"01M1CF05YVB15M9QDPG9644SKY","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"} +{"actor":"richardhightower","ev":"01M1CF063XA8WBH3RWYYG4NRXM","item":"01M1CEEP1EG2793CSYT4AJC8K5","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:45Z"} diff --git a/docs/plans/2026-08-31-fix-issue-69-dry-run.md b/docs/plans/2026-08-31-fix-issue-69-dry-run.md new file mode 100644 index 0000000..3d497ec --- /dev/null +++ b/docs/plans/2026-08-31-fix-issue-69-dry-run.md @@ -0,0 +1,16 @@ +--- +date: 2026-08-31 +slug: fix-issue-69-dry-run +title: Fix issue #69 dry-run writes +epic: 01M1CEEP1EG2793CSYT4AJC8K5 +items: [01M1CEEP1EJMGSW2DP0PZHMM0B, 01M1CEEP1E29BXX7PS90ZQ44R8, 01M1CEEP1EBGFT5DR8TJRGBC5M] +--- + +## Tasks + +- [ ] Reproduce dry-run filesystem mutations + Confirm concept, catalog, log, and write-event behavior before changing implementation. +- [ ] Implement side-effect-free dry-run behavior + Report planned actions without creating, updating, or deleting bundle content. +- [ ] Add and run regression coverage + Snapshot the target tree and verify all dry-run output modes leave it unchanged. diff --git a/docs/roadmap.md b/docs/roadmap.md index f1c19eb..1830135 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -2,8 +2,8 @@ wiki_key: roadmap doc_type: roadmap truth_state: current -source_hash: 8de8b50a -generated_at: 2026-08-30T16:52:55Z +source_hash: e86bd157 +generated_at: 2026-08-31T17:48:45Z --- diff --git a/scripts/pkc_common.py b/scripts/pkc_common.py index 7967227..773a52a 100644 --- a/scripts/pkc_common.py +++ b/scripts/pkc_common.py @@ -459,13 +459,14 @@ def write_concept( *, merge: bool = True, create_only: bool = False, + dry_run: bool = False, ) -> tuple[Path, str]: """Write a concept. Returns (path, action). action is one of "created", "updated", "skipped", "exists", "refused". + With ``dry_run=True``, compute the same action without changing the filesystem. """ path = bundle / rel_path.lstrip("/") - path.parent.mkdir(parents=True, exist_ok=True) if "timestamp" not in frontmatter: frontmatter = {**frontmatter, "timestamp": utc_now()} if path.is_file(): @@ -489,15 +490,19 @@ def write_concept( content = dump_frontmatter(new_fm) + "\n" + (body.strip() or old_body).rstrip() + "\n" if content == existing: return path, "skipped" - path.write_text(content, encoding="utf-8") + if not dry_run: + path.write_text(content, encoding="utf-8") return path, "updated" content = dump_frontmatter(frontmatter) + "\n" + body.rstrip() + "\n" if content == existing: return path, "skipped" - path.write_text(content, encoding="utf-8") + if not dry_run: + path.write_text(content, encoding="utf-8") return path, "updated" fm = {k: v for k, v in frontmatter.items() if k not in ("force", "stable_timestamp")} - path.write_text(dump_frontmatter(fm) + "\n" + body.rstrip() + "\n", encoding="utf-8") + if not dry_run: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(dump_frontmatter(fm) + "\n" + body.rstrip() + "\n", encoding="utf-8") return path, "created" @@ -927,6 +932,7 @@ def write_knowledge( merge: bool = True, create_only: bool = False, emit_event: bool = True, + dry_run: bool = False, ) -> tuple[Path, str]: """Stamp author, write via write_concept, emit WriteEvent on created/updated. @@ -936,9 +942,9 @@ def write_knowledge( resolve_author(author) fm = {**frontmatter, "author": author} path, action = write_concept( - bundle, rel_path, fm, body, merge=merge, create_only=create_only + bundle, rel_path, fm, body, merge=merge, create_only=create_only, dry_run=dry_run ) - if emit_event and action in ("created", "updated"): + if not dry_run and emit_event and action in ("created", "updated"): emit_write_event( bundle, author=author, diff --git a/scripts/pkc_materialize.py b/scripts/pkc_materialize.py index 5d10867..762895c 100755 --- a/scripts/pkc_materialize.py +++ b/scripts/pkc_materialize.py @@ -124,6 +124,7 @@ def materialize_item( include: set[str], author: str, force: bool = False, + dry_run: bool = False, ) -> list[tuple[str, str, str]]: results: list[tuple[str, str, str]] = [] fingerprint = item_fingerprint(item) @@ -195,7 +196,9 @@ def materialize_item( body += f"- Worklog ULID: `{ulid}`\n" if external_id: body += f"- External: {external_system} `{external_id}`\n" - _, action = write_knowledge(bundle, feature_rel, fm, body, author=author) + _, action = write_knowledge( + bundle, feature_rel, fm, body, author=author, dry_run=dry_run + ) results.append((feature_rel, action, "Feature")) trel: str | None = None @@ -234,7 +237,9 @@ def materialize_item( if external_id: tbody += f"- System: {external_system}\n- ID: `{external_id}`\n" tbody += f"\n## Status\n\n`{status}` · level `{level}` · kind `{kind}`\n" - _, action = write_knowledge(bundle, trel, tfm, tbody, author=author) + _, action = write_knowledge( + bundle, trel, tfm, tbody, author=author, dry_run=dry_run + ) results.append((trel, action, "TicketLink")) work_type = work_concept_type(level, kind) @@ -299,7 +304,9 @@ def materialize_item( wbody += "\n" + body_src.strip() + "\n" if ulid: wbody += f"\n## Provenance\n\n- Worklog ULID: `{ulid}`\n" - _, action = write_knowledge(bundle, wrel, wfm, wbody, author=author) + _, action = write_knowledge( + bundle, wrel, wfm, wbody, author=author, dry_run=dry_run + ) results.append((wrel, action, work_type)) branch_name = item.get("branch") @@ -332,7 +339,9 @@ def materialize_item( if force: bfm["force"] = True bbody = f"# {branch_name}\n\nSource-control branch materialized from a work item.\n" - _, action = write_knowledge(bundle, brel, bfm, bbody, author=author) + _, action = write_knowledge( + bundle, brel, bfm, bbody, author=author, dry_run=dry_run + ) results.append((brel, action, "Branch")) return results @@ -345,6 +354,7 @@ def materialize_docs( include: set[str], author: str, force: bool = False, + dry_run: bool = False, ) -> list[tuple[str, str, str]]: results: list[tuple[str, str, str]] = [] docs = repo / "docs" @@ -356,28 +366,40 @@ def materialize_docs( for path in docs.glob(pattern): if path.name.lower() in ("index.md", "readme.md"): continue - results.extend(_doc_to_concept(path, bundle, "DecisionRecord", "decisions", force, author)) + results.extend( + _doc_to_concept( + path, bundle, "DecisionRecord", "decisions", force, author, dry_run + ) + ) if "designs" in include: for pattern in ("**/design*/**/*.md", "**/walkthrough*/**/*.md"): for path in docs.glob(pattern): if path.name.lower() in ("index.md", "readme.md"): continue - results.extend(_doc_to_concept(path, bundle, "Design", "designs", force, author)) + results.extend( + _doc_to_concept(path, bundle, "Design", "designs", force, author, dry_run) + ) if "specs" in include: for pattern in ("**/plans/**/*.md", "**/plan*.md"): for path in docs.glob(pattern): if path.name.lower() in ("index.md", "readme.md"): continue - results.extend(_doc_to_concept(path, bundle, "Specification", "specs", force, author)) + results.extend( + _doc_to_concept( + path, bundle, "Specification", "specs", force, author, dry_run + ) + ) if "releases" in include: for pattern in ("**/releases/**/*.md",): for path in docs.glob(pattern): if path.name.lower() in ("index.md", "readme.md"): continue - results.extend(_doc_to_concept(path, bundle, "Release", "releases", force, author)) + results.extend( + _doc_to_concept(path, bundle, "Release", "releases", force, author, dry_run) + ) # de-dupe by path (glob overlaps) seen: set[str] = set() @@ -397,6 +419,7 @@ def _doc_to_concept( catalog: str, force: bool, author: str, + dry_run: bool, ) -> list[tuple[str, str, str]]: text = path.read_text(encoding="utf-8") body = text @@ -427,7 +450,9 @@ def _doc_to_concept( if force: fm["force"] = True content_body = f"# {title}\n\n> Source: `{path.as_posix()}`\n\n{body.strip()}\n" - _, action = write_knowledge(bundle, rel, fm, content_body, author=author) + _, action = write_knowledge( + bundle, rel, fm, content_body, author=author, dry_run=dry_run + ) return [(rel, action, concept_type)] @@ -437,8 +462,27 @@ def _doc_to_concept( # index would say, so none of them justify rewriting one. WROTE = ("created", "updated") +DRY_RUN_ACTIONS = { + "created": "would_create", + "updated": "would_update", + "skipped": "would_skip", + "exists": "would_exist", + "refused": "would_refuse", + "unchanged": "unchanged", +} + + +def report_entry( + rel: str, action: str, kind: str, *, dry_run: bool +) -> dict[str, Any]: + result: dict[str, Any] = {"path": rel, "action": action, "type": kind} + if dry_run: + result["action"] = DRY_RUN_ACTIONS.get(action, f"would_{action}") + result["planned"] = True + return result + -def catalogs_touched(report: list[dict[str, str]]) -> set[str]: +def catalogs_touched(report: list[dict[str, Any]]) -> set[str]: cats: set[str] = set() for r in report: if r["action"] not in WROTE: @@ -477,7 +521,7 @@ def main(argv: list[str] | None = None) -> int: else: author = args.author or "dry-run" - report: list[dict[str, str]] = [] + report: list[dict[str, Any]] = [] if from_worklog: fold = load_fold(args.fold) for item in fold.get("items") or []: @@ -486,15 +530,25 @@ def main(argv: list[str] | None = None) -> int: if item_status(item) == "cancelled" and not args.force: continue for rel, action, kind in materialize_item( - bundle, item, include=include, author=author, force=args.force + bundle, + item, + include=include, + author=author, + force=args.force, + dry_run=args.dry_run, ): - report.append({"path": rel, "action": action, "type": kind}) + report.append(report_entry(rel, action, kind, dry_run=args.dry_run)) if args.from_docs: for rel, action, kind in materialize_docs( - repo, bundle, include=include, author=author, force=args.force + repo, + bundle, + include=include, + author=author, + force=args.force, + dry_run=args.dry_run, ): - report.append({"path": rel, "action": action, "type": kind}) + report.append(report_entry(rel, action, kind, dry_run=args.dry_run)) if not args.dry_run: for cat in sorted(catalogs_touched(report)): @@ -515,21 +569,38 @@ def main(argv: list[str] | None = None) -> int: ) if args.json: - print(json.dumps({"bundle": str(bundle), "results": report}, indent=2)) + print( + json.dumps( + {"bundle": str(bundle), "dry_run": args.dry_run, "results": report}, + indent=2, + ) + ) else: print(f"Bundle: {bundle}") - counts = {"created": 0, "updated": 0, "skipped": 0, "unchanged": 0, "refused": 0} + if args.dry_run: + print("Dry run: no changes applied") + counts: dict[str, int] = {} for r in report: - counts[r["action"]] = counts.get(r["action"], 0) + 1 - print(f" [{r['action']:9}] {r['type']:14} {r['path']}") + action = str(r["action"]) + counts[action] = counts.get(action, 0) + 1 + print(f" [{action:13}] {r['type']:14} {r['path']}") # `unchanged` = short-circuited on fingerprint, never rendered. # `skipped` = rendered, compared, found identical (or truth_state barrier). # The split is what lets CI prove incremental materialize actually works. - print( - f"Summary: {counts.get('created', 0)} created, " - f"{counts.get('updated', 0)} updated, {counts.get('skipped', 0)} skipped, " - f"{counts.get('unchanged', 0)} unchanged" - ) + if args.dry_run: + print( + f"Summary: {counts.get('would_create', 0)} would create, " + f"{counts.get('would_update', 0)} would update, " + f"{counts.get('would_skip', 0)} would skip, " + f"{counts.get('unchanged', 0)} unchanged" + ) + else: + print( + f"Summary: {counts.get('created', 0)} created, " + f"{counts.get('updated', 0)} updated, " + f"{counts.get('skipped', 0)} skipped, " + f"{counts.get('unchanged', 0)} unchanged" + ) return 0 diff --git a/tests/test_pkc.py b/tests/test_pkc.py index 60331e6..37f2301 100644 --- a/tests/test_pkc.py +++ b/tests/test_pkc.py @@ -5,6 +5,7 @@ import contextlib import hashlib +import io import json import os import shutil @@ -391,6 +392,91 @@ def test_fold_materialize(self): features2 = [p for p in (self.bundle / "features").glob("*.md") if p.name != "index.md"] self.assertEqual(len(features2), 2) + def test_dry_run_leaves_existing_bundle_byte_for_byte_unchanged(self): + fold = self.tmp / "fold.json" + existing = { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAV", + "title": "Existing task", + "level": "task", + "kind": "ops", + "status": "todo", + } + fold.write_text(json.dumps({"items": [existing]}), encoding="utf-8") + self.assertEqual( + materialize_main( + [ + "--repo", str(self.tmp), "--bundle", "knowledge", + "--fold", str(fold), "--include", "tickets", "--author", AUTHOR, + ] + ), + 0, + ) + + def snapshot_tree(): + return { + str(path.relative_to(self.tmp)): ( + "dir" if path.is_dir() else hashlib.sha256(path.read_bytes()).hexdigest() + ) + for path in sorted(self.tmp.rglob("*")) + } + + before = snapshot_tree() + self.assertTrue(any(path.endswith("/index.md") for path in before)) + self.assertIn("knowledge/log.md", before) + self.assertTrue(any("knowledge/write-events/" in path for path in before)) + self.assertTrue(any(path.endswith(".md") and "/tasks/" in path for path in before)) + + fold.write_text( + json.dumps( + { + "items": [ + {**existing, "title": "Updated existing task"}, + { + "id": "01ARZ3NDEKTSV4RRFFQ69G5FAW", + "title": "Preview-only task", + "level": "task", + "kind": "ops", + "status": "todo", + }, + ] + } + ), + encoding="utf-8", + ) + before = snapshot_tree() + + json_out = io.StringIO() + with contextlib.redirect_stdout(json_out): + rc = materialize_main( + [ + "--repo", str(self.tmp), "--bundle", "knowledge", + "--fold", str(fold), "--include", "tickets", "--author", AUTHOR, + "--dry-run", "--json", + ] + ) + self.assertEqual(rc, 0) + report = json.loads(json_out.getvalue()) + self.assertTrue(report["dry_run"]) + actions = {result["action"] for result in report["results"]} + self.assertIn("would_update", actions) + self.assertIn("would_create", actions) + self.assertTrue(all(result["planned"] for result in report["results"])) + self.assertEqual(before, snapshot_tree()) + + text_out = io.StringIO() + with contextlib.redirect_stdout(text_out): + rc = materialize_main( + [ + "--repo", str(self.tmp), "--bundle", "knowledge", + "--fold", str(fold), "--include", "tickets", "--author", AUTHOR, + "--dry-run", + ] + ) + self.assertEqual(rc, 0) + self.assertIn("Dry run: no changes applied", text_out.getvalue()) + self.assertIn("[would_create", text_out.getvalue()) + self.assertEqual(before, snapshot_tree()) + class TestIncrementalMaterialize(unittest.TestCase): """Unchanged worklog items must be skipped without re-rendering the concept."""