Skip to content

Commit 17ea209

Browse files
fix: make materialize dry-run side-effect-free (#69)
1 parent 2ff978a commit 17ea209

6 files changed

Lines changed: 223 additions & 32 deletions

File tree

.work/todo.jsonl

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,15 @@
9494
{"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"}
9595
{"actor":"grok-build/wiki-publish","ev":"01M19SBCRR96E9536RS3WZ35DH","item":"01M19SADGDHJPCXX5MK4A76ZE5","op":"update","set":{"status":"in_progress"},"ts":"2026-08-30T16:51:54Z"}
9696
{"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"}
97+
{"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"}
98+
{"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"}
99+
{"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"}
100+
{"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"}
101+
{"actor":"richardhightower","ev":"01M1CEFN30X99S04ZEJJVMHFYM","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:39:43Z"}
102+
{"actor":"richardhightower","ev":"01M1CF0544EZ9W1M17ZNJRKQT8","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"}
103+
{"actor":"richardhightower","ev":"01M1CF058FCJMMPWB7DTVFW7B8","item":"01M1CEEP1EJMGSW2DP0PZHMM0B","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"}
104+
{"actor":"richardhightower","ev":"01M1CF05DTWPCA4XJKQM3BBQFZ","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"}
105+
{"actor":"richardhightower","ev":"01M1CF05M57TJM9X3XVZWJW0VP","item":"01M1CEEP1E29BXX7PS90ZQ44R8","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"}
106+
{"actor":"richardhightower","ev":"01M1CF05SSATV2H6HDDYGTPGQP","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"update","set":{"status":"in_progress"},"ts":"2026-08-31T17:48:44Z"}
107+
{"actor":"richardhightower","ev":"01M1CF05YVB15M9QDPG9644SKY","item":"01M1CEEP1EBGFT5DR8TJRGBC5M","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:44Z"}
108+
{"actor":"richardhightower","ev":"01M1CF063XA8WBH3RWYYG4NRXM","item":"01M1CEEP1EG2793CSYT4AJC8K5","op":"close","set":{"resolution":"completed","status":"done"},"ts":"2026-08-31T17:48:45Z"}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
date: 2026-08-31
3+
slug: fix-issue-69-dry-run
4+
title: Fix issue #69 dry-run writes
5+
epic: 01M1CEEP1EG2793CSYT4AJC8K5
6+
items: [01M1CEEP1EJMGSW2DP0PZHMM0B, 01M1CEEP1E29BXX7PS90ZQ44R8, 01M1CEEP1EBGFT5DR8TJRGBC5M]
7+
---
8+
9+
## Tasks
10+
11+
- [ ] Reproduce dry-run filesystem mutations
12+
Confirm concept, catalog, log, and write-event behavior before changing implementation.
13+
- [ ] Implement side-effect-free dry-run behavior
14+
Report planned actions without creating, updating, or deleting bundle content.
15+
- [ ] Add and run regression coverage
16+
Snapshot the target tree and verify all dry-run output modes leave it unchanged.

docs/roadmap.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22
wiki_key: roadmap
33
doc_type: roadmap
44
truth_state: current
5-
source_hash: 8de8b50a
6-
generated_at: 2026-08-30T16:52:55Z
5+
source_hash: e86bd157
6+
generated_at: 2026-08-31T17:48:45Z
77
---
88

99
<!-- GENERATED by worklog roadmap-render. DO NOT EDIT. -->

scripts/pkc_common.py

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -459,13 +459,14 @@ def write_concept(
459459
*,
460460
merge: bool = True,
461461
create_only: bool = False,
462+
dry_run: bool = False,
462463
) -> tuple[Path, str]:
463464
"""Write a concept. Returns (path, action).
464465
465466
action is one of "created", "updated", "skipped", "exists", "refused".
467+
With ``dry_run=True``, compute the same action without changing the filesystem.
466468
"""
467469
path = bundle / rel_path.lstrip("/")
468-
path.parent.mkdir(parents=True, exist_ok=True)
469470
if "timestamp" not in frontmatter:
470471
frontmatter = {**frontmatter, "timestamp": utc_now()}
471472
if path.is_file():
@@ -489,15 +490,19 @@ def write_concept(
489490
content = dump_frontmatter(new_fm) + "\n" + (body.strip() or old_body).rstrip() + "\n"
490491
if content == existing:
491492
return path, "skipped"
492-
path.write_text(content, encoding="utf-8")
493+
if not dry_run:
494+
path.write_text(content, encoding="utf-8")
493495
return path, "updated"
494496
content = dump_frontmatter(frontmatter) + "\n" + body.rstrip() + "\n"
495497
if content == existing:
496498
return path, "skipped"
497-
path.write_text(content, encoding="utf-8")
499+
if not dry_run:
500+
path.write_text(content, encoding="utf-8")
498501
return path, "updated"
499502
fm = {k: v for k, v in frontmatter.items() if k not in ("force", "stable_timestamp")}
500-
path.write_text(dump_frontmatter(fm) + "\n" + body.rstrip() + "\n", encoding="utf-8")
503+
if not dry_run:
504+
path.parent.mkdir(parents=True, exist_ok=True)
505+
path.write_text(dump_frontmatter(fm) + "\n" + body.rstrip() + "\n", encoding="utf-8")
501506
return path, "created"
502507

503508

@@ -927,6 +932,7 @@ def write_knowledge(
927932
merge: bool = True,
928933
create_only: bool = False,
929934
emit_event: bool = True,
935+
dry_run: bool = False,
930936
) -> tuple[Path, str]:
931937
"""Stamp author, write via write_concept, emit WriteEvent on created/updated.
932938
@@ -936,9 +942,9 @@ def write_knowledge(
936942
resolve_author(author)
937943
fm = {**frontmatter, "author": author}
938944
path, action = write_concept(
939-
bundle, rel_path, fm, body, merge=merge, create_only=create_only
945+
bundle, rel_path, fm, body, merge=merge, create_only=create_only, dry_run=dry_run
940946
)
941-
if emit_event and action in ("created", "updated"):
947+
if not dry_run and emit_event and action in ("created", "updated"):
942948
emit_write_event(
943949
bundle,
944950
author=author,

scripts/pkc_materialize.py

Lines changed: 95 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ def materialize_item(
124124
include: set[str],
125125
author: str,
126126
force: bool = False,
127+
dry_run: bool = False,
127128
) -> list[tuple[str, str, str]]:
128129
results: list[tuple[str, str, str]] = []
129130
fingerprint = item_fingerprint(item)
@@ -195,7 +196,9 @@ def materialize_item(
195196
body += f"- Worklog ULID: `{ulid}`\n"
196197
if external_id:
197198
body += f"- External: {external_system} `{external_id}`\n"
198-
_, action = write_knowledge(bundle, feature_rel, fm, body, author=author)
199+
_, action = write_knowledge(
200+
bundle, feature_rel, fm, body, author=author, dry_run=dry_run
201+
)
199202
results.append((feature_rel, action, "Feature"))
200203

201204
trel: str | None = None
@@ -234,7 +237,9 @@ def materialize_item(
234237
if external_id:
235238
tbody += f"- System: {external_system}\n- ID: `{external_id}`\n"
236239
tbody += f"\n## Status\n\n`{status}` · level `{level}` · kind `{kind}`\n"
237-
_, action = write_knowledge(bundle, trel, tfm, tbody, author=author)
240+
_, action = write_knowledge(
241+
bundle, trel, tfm, tbody, author=author, dry_run=dry_run
242+
)
238243
results.append((trel, action, "TicketLink"))
239244

240245
work_type = work_concept_type(level, kind)
@@ -299,7 +304,9 @@ def materialize_item(
299304
wbody += "\n" + body_src.strip() + "\n"
300305
if ulid:
301306
wbody += f"\n## Provenance\n\n- Worklog ULID: `{ulid}`\n"
302-
_, action = write_knowledge(bundle, wrel, wfm, wbody, author=author)
307+
_, action = write_knowledge(
308+
bundle, wrel, wfm, wbody, author=author, dry_run=dry_run
309+
)
303310
results.append((wrel, action, work_type))
304311

305312
branch_name = item.get("branch")
@@ -332,7 +339,9 @@ def materialize_item(
332339
if force:
333340
bfm["force"] = True
334341
bbody = f"# {branch_name}\n\nSource-control branch materialized from a work item.\n"
335-
_, action = write_knowledge(bundle, brel, bfm, bbody, author=author)
342+
_, action = write_knowledge(
343+
bundle, brel, bfm, bbody, author=author, dry_run=dry_run
344+
)
336345
results.append((brel, action, "Branch"))
337346

338347
return results
@@ -345,6 +354,7 @@ def materialize_docs(
345354
include: set[str],
346355
author: str,
347356
force: bool = False,
357+
dry_run: bool = False,
348358
) -> list[tuple[str, str, str]]:
349359
results: list[tuple[str, str, str]] = []
350360
docs = repo / "docs"
@@ -356,28 +366,40 @@ def materialize_docs(
356366
for path in docs.glob(pattern):
357367
if path.name.lower() in ("index.md", "readme.md"):
358368
continue
359-
results.extend(_doc_to_concept(path, bundle, "DecisionRecord", "decisions", force, author))
369+
results.extend(
370+
_doc_to_concept(
371+
path, bundle, "DecisionRecord", "decisions", force, author, dry_run
372+
)
373+
)
360374

361375
if "designs" in include:
362376
for pattern in ("**/design*/**/*.md", "**/walkthrough*/**/*.md"):
363377
for path in docs.glob(pattern):
364378
if path.name.lower() in ("index.md", "readme.md"):
365379
continue
366-
results.extend(_doc_to_concept(path, bundle, "Design", "designs", force, author))
380+
results.extend(
381+
_doc_to_concept(path, bundle, "Design", "designs", force, author, dry_run)
382+
)
367383

368384
if "specs" in include:
369385
for pattern in ("**/plans/**/*.md", "**/plan*.md"):
370386
for path in docs.glob(pattern):
371387
if path.name.lower() in ("index.md", "readme.md"):
372388
continue
373-
results.extend(_doc_to_concept(path, bundle, "Specification", "specs", force, author))
389+
results.extend(
390+
_doc_to_concept(
391+
path, bundle, "Specification", "specs", force, author, dry_run
392+
)
393+
)
374394

375395
if "releases" in include:
376396
for pattern in ("**/releases/**/*.md",):
377397
for path in docs.glob(pattern):
378398
if path.name.lower() in ("index.md", "readme.md"):
379399
continue
380-
results.extend(_doc_to_concept(path, bundle, "Release", "releases", force, author))
400+
results.extend(
401+
_doc_to_concept(path, bundle, "Release", "releases", force, author, dry_run)
402+
)
381403

382404
# de-dupe by path (glob overlaps)
383405
seen: set[str] = set()
@@ -397,6 +419,7 @@ def _doc_to_concept(
397419
catalog: str,
398420
force: bool,
399421
author: str,
422+
dry_run: bool,
400423
) -> list[tuple[str, str, str]]:
401424
text = path.read_text(encoding="utf-8")
402425
body = text
@@ -427,7 +450,9 @@ def _doc_to_concept(
427450
if force:
428451
fm["force"] = True
429452
content_body = f"# {title}\n\n> Source: `{path.as_posix()}`\n\n{body.strip()}\n"
430-
_, action = write_knowledge(bundle, rel, fm, content_body, author=author)
453+
_, action = write_knowledge(
454+
bundle, rel, fm, content_body, author=author, dry_run=dry_run
455+
)
431456
return [(rel, action, concept_type)]
432457

433458

@@ -437,8 +462,27 @@ def _doc_to_concept(
437462
# index would say, so none of them justify rewriting one.
438463
WROTE = ("created", "updated")
439464

465+
DRY_RUN_ACTIONS = {
466+
"created": "would_create",
467+
"updated": "would_update",
468+
"skipped": "would_skip",
469+
"exists": "would_exist",
470+
"refused": "would_refuse",
471+
"unchanged": "unchanged",
472+
}
473+
474+
475+
def report_entry(
476+
rel: str, action: str, kind: str, *, dry_run: bool
477+
) -> dict[str, Any]:
478+
result: dict[str, Any] = {"path": rel, "action": action, "type": kind}
479+
if dry_run:
480+
result["action"] = DRY_RUN_ACTIONS.get(action, f"would_{action}")
481+
result["planned"] = True
482+
return result
483+
440484

441-
def catalogs_touched(report: list[dict[str, str]]) -> set[str]:
485+
def catalogs_touched(report: list[dict[str, Any]]) -> set[str]:
442486
cats: set[str] = set()
443487
for r in report:
444488
if r["action"] not in WROTE:
@@ -477,7 +521,7 @@ def main(argv: list[str] | None = None) -> int:
477521
else:
478522
author = args.author or "dry-run"
479523

480-
report: list[dict[str, str]] = []
524+
report: list[dict[str, Any]] = []
481525
if from_worklog:
482526
fold = load_fold(args.fold)
483527
for item in fold.get("items") or []:
@@ -486,15 +530,25 @@ def main(argv: list[str] | None = None) -> int:
486530
if item_status(item) == "cancelled" and not args.force:
487531
continue
488532
for rel, action, kind in materialize_item(
489-
bundle, item, include=include, author=author, force=args.force
533+
bundle,
534+
item,
535+
include=include,
536+
author=author,
537+
force=args.force,
538+
dry_run=args.dry_run,
490539
):
491-
report.append({"path": rel, "action": action, "type": kind})
540+
report.append(report_entry(rel, action, kind, dry_run=args.dry_run))
492541

493542
if args.from_docs:
494543
for rel, action, kind in materialize_docs(
495-
repo, bundle, include=include, author=author, force=args.force
544+
repo,
545+
bundle,
546+
include=include,
547+
author=author,
548+
force=args.force,
549+
dry_run=args.dry_run,
496550
):
497-
report.append({"path": rel, "action": action, "type": kind})
551+
report.append(report_entry(rel, action, kind, dry_run=args.dry_run))
498552

499553
if not args.dry_run:
500554
for cat in sorted(catalogs_touched(report)):
@@ -515,21 +569,38 @@ def main(argv: list[str] | None = None) -> int:
515569
)
516570

517571
if args.json:
518-
print(json.dumps({"bundle": str(bundle), "results": report}, indent=2))
572+
print(
573+
json.dumps(
574+
{"bundle": str(bundle), "dry_run": args.dry_run, "results": report},
575+
indent=2,
576+
)
577+
)
519578
else:
520579
print(f"Bundle: {bundle}")
521-
counts = {"created": 0, "updated": 0, "skipped": 0, "unchanged": 0, "refused": 0}
580+
if args.dry_run:
581+
print("Dry run: no changes applied")
582+
counts: dict[str, int] = {}
522583
for r in report:
523-
counts[r["action"]] = counts.get(r["action"], 0) + 1
524-
print(f" [{r['action']:9}] {r['type']:14} {r['path']}")
584+
action = str(r["action"])
585+
counts[action] = counts.get(action, 0) + 1
586+
print(f" [{action:13}] {r['type']:14} {r['path']}")
525587
# `unchanged` = short-circuited on fingerprint, never rendered.
526588
# `skipped` = rendered, compared, found identical (or truth_state barrier).
527589
# The split is what lets CI prove incremental materialize actually works.
528-
print(
529-
f"Summary: {counts.get('created', 0)} created, "
530-
f"{counts.get('updated', 0)} updated, {counts.get('skipped', 0)} skipped, "
531-
f"{counts.get('unchanged', 0)} unchanged"
532-
)
590+
if args.dry_run:
591+
print(
592+
f"Summary: {counts.get('would_create', 0)} would create, "
593+
f"{counts.get('would_update', 0)} would update, "
594+
f"{counts.get('would_skip', 0)} would skip, "
595+
f"{counts.get('unchanged', 0)} unchanged"
596+
)
597+
else:
598+
print(
599+
f"Summary: {counts.get('created', 0)} created, "
600+
f"{counts.get('updated', 0)} updated, "
601+
f"{counts.get('skipped', 0)} skipped, "
602+
f"{counts.get('unchanged', 0)} unchanged"
603+
)
533604
return 0
534605

535606

0 commit comments

Comments
 (0)