From 5d764d1fad1ee353abff92f15cc3bdfc8e8856b7 Mon Sep 17 00:00:00 2001 From: Steve-too Date: Sat, 8 Aug 2026 08:55:26 +0000 Subject: [PATCH 1/6] feat(cli): import-md markdown folder importer (#744) recursively walks a folder for .md files and runs the same mechanical ingest_source per file: each file is registered as a content-addressed source, receipt-backed claims are filed and auto-approved when review.auto_approve_on_receipt is on -- an import is a capture firehose, never a review-gate bypass. re-runs skip unchanged files via a per-file content hash in .vouch/md_import_state.json (same pattern as inbox-state.json). an edited file is fully re-ingested -- a documented limitation while claim-level diffing waits on the full #612 track. --max-claims/--budget-chars reuse the existing density knobs so a large vault doesn't firehose ten thousand pending spans. purely additive: one command, one module, one sidecar file. closes #744. no kb.* method -- an import is a deliberate human action, same reasoning as kb.import_apply staying CLI-only. --- CHANGELOG.md | 16 ++++ src/vouch/cli.py | 76 ++++++++++++++++ src/vouch/md_import.py | 169 ++++++++++++++++++++++++++++++++++ tests/test_md_import.py | 194 ++++++++++++++++++++++++++++++++++++++++ 4 files changed, 455 insertions(+) create mode 100644 src/vouch/md_import.py create mode 100644 tests/test_md_import.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b0113b4..273f6909 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,22 @@ All notable changes to vouch are documented here. Format follows ## [Unreleased] ### Added +- **cli: `vouch import-md` -- markdown-folder importer** (#744): a folder of + markdown notes could not enter a fresh KB without hand-importing each file + -- `vouch ingest` works per file, the inbox drop has no recursive walk or + re-run tracking. `vouch import-md ` recursively walks `*.md` + (hidden dot-directories pruned, symlinked directories never chased) and + runs the same mechanical `extract.ingest_source` per file: each file is + registered as a content-addressed source, receipt-backed claims are filed + for its quotable spans, auto-approved when -- and only when -- + `review.auto_approve_on_receipt` is on. Re-runs are idempotent for + unchanged files through a per-file content hash in + `.vouch/md_import_state.json`; an EDITED file is fully re-ingested + (documented limitation -- claim-level diffing belongs to the full #612 + track). `--max-claims`/`--budget-chars` reuse the existing density + selection so a large vault doesn't firehose ten thousand spans; + `--no-approve`, `--min-chars`, `--json`. Purely additive: one command, + one module, one bookkeeping file. - **bench: composite guards** (#616): `efficiency`, `consistency` and `canary` as bounded multipliers over the composite, plus a `bench_version` stamp on every report. Reported **beside** the composite, never folded into it — diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 1f1aebc8..72752f73 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -42,6 +42,7 @@ from . import inbox as inbox_mod from . import install_adapter as install_mod from . import lifecycle as life +from . import md_import as md_import_mod from . import media as media_mod from . import metrics as metrics_mod from . import migrations as migrations_mod @@ -4722,6 +4723,81 @@ def import_chatgpt_cmd( _echo("run `vouch review` to decide.") +@cli.command("import-md") +@click.argument( + "folder", type=click.Path(exists=True, file_okay=False, path_type=Path) +) +@click.option( + "--max-claims", type=int, default=None, + help="Per file, keep only the N most information-dense spans " + "(density selection). Unset captures every quotable span.", +) +@click.option( + "--budget-chars", type=int, default=None, + help="Per file, keep the densest spans that fit within this many " + "characters.", +) +@click.option( + "--no-approve", is_flag=True, + help="File the claims but never auto-approve, even if the receipt " + "gate is on.", +) +@click.option( + "--min-chars", type=int, default=md_import_mod.DEFAULT_MIN_CHARS, + show_default=True, + help="Skip files shorter than this after whitespace-stripping.", +) +@click.option("--json", "as_json", is_flag=True, help="Machine-readable report.") +def import_md_cmd( + folder: Path, + max_claims: int | None, + budget_chars: int | None, + no_approve: bool, + min_chars: int, + as_json: bool, +) -> None: + """Import a markdown folder one file at a time, through the receipt gate. + + Recursively walks FOLDER for *.md files and runs the same mechanical + ingest `vouch ingest` runs on one file: each file is registered as a + content-addressed source via extract.ingest_source, receipt-backed + claims are filed for its quotable spans, and they are auto-approved + when -- and only when -- review.auto_approve_on_receipt is on. + + Re-runs skip unchanged files (per-file content hash in + .vouch/md_import_state.json); an EDITED file is fully re-ingested -- + there is no claim-level diffing against what an earlier version of + the same note already contributed. Review with `vouch review`. + """ + store = _load_store() + with _cli_errors(): + report = md_import_mod.import_folder( + store, + folder, + auto_approve=not no_approve, + max_claims=max_claims, + budget_chars=budget_chars, + min_chars=min_chars, + ) + if as_json: + _emit_json(report) + return + _echo( + f"{report['files']} markdown file(s) -- ingested {report['ingested']}, " + f"skipped {report['skipped']}, {report['approved']} claim(s) " + f"auto-approved, {report['pending_claims']} pending review" + ) + for row in report["rows"]: + if row["action"] == "skipped": + continue + _echo( + f" - {row['source'][:12]}... {row['path']} " + f"(+{row['approved']} approved)" + ) + if report["pending_claims"]: + _echo("run `vouch review` to decide.") + + # --- auto-pr: open N mergeable PRs against any github repo ----------------- diff --git a/src/vouch/md_import.py b/src/vouch/md_import.py new file mode 100644 index 00000000..d0ad90aa --- /dev/null +++ b/src/vouch/md_import.py @@ -0,0 +1,169 @@ +"""Import a markdown folder one file at a time, through the receipt gate. + +The argument for building this rather than telling people "convert your +notes and use ``vouch ingest`` per file": registering the source from the +note's own bytes means an extracted claim quotes real offsets and its +receipt verifies -- a property you cannot get by converting first, and it +falls out of ``extract.ingest_source`` for free. + +``vouch import-md `` walks a folder recursively for ``*.md`` files +and runs the same mechanical ingest ``vouch ingest`` runs on one file: +each file is registered as a content-addressed source via +``extract.ingest_source``, receipt-backed claims are filed for its +quotable spans, and they are auto-approved when (and only when) +``review.auto_approve_on_receipt`` is on. An import is a capture +firehose, never a review-gate bypass. + +Re-running is idempotent for unchanged files: a per-file content hash in +``.vouch/md_import_state.json`` makes a re-run against the same vault a +no-op for anything that has not changed. An EDITED file is fully +re-ingested -- a documented limitation, not a silent gap: +``extract.ingest_source`` has no way to diff against what a prior version +of the same file already contributed, so a changed note files a fresh +batch of claims for its current content, on top of what is already +there. True claim-level diffing is a harder problem the #612 +multi-format track can take on later. +""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any + +from . import extract as extract_mod +from .models import ProposalKind, ProposalStatus +from .storage import KBStore, sha256_hex + +STATE_FILENAME = "md_import_state.json" + +# The default proposer when VOUCH_AGENT isn't set -- mirroring +# "chatgpt-import": an import is a human choosing to file their vault, +# so admission verdicts stay advisory and its claims reach review +# instead of being auto-rejected as capture noise. +MD_IMPORT_ACTOR = "md-import" + +# The inbox importer's noise floor: below this many non-whitespace +# characters a file has no quotable span worth a claim. +DEFAULT_MIN_CHARS = 40 + + +def _state_path(store: KBStore) -> Path: + return store.kb_dir / STATE_FILENAME + + +def _load_state(store: KBStore) -> dict[str, str]: + try: + loaded = json.loads(_state_path(store).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + if not isinstance(loaded, dict): + return {} + return {str(k): str(v) for k, v in loaded.items()} + + +def _save_state(store: KBStore, state: dict[str, str]) -> None: + _state_path(store).write_text( + json.dumps(state, indent=1, sort_keys=True), encoding="utf-8" + ) + + +def _iter_markdown(root: Path) -> list[Path]: + """Every ``*.md`` file under ``root``, in deterministic review order. + + ``os.walk`` with ``followlinks=False`` never chases symlinked + directories, and dot-directories (``.git``, ``.vouch``, + ``.obsidian``) are pruned -- vault tooling writes state there, not + notes. Per-file safety after the walk stays with ``read_under_root``. + """ + found: list[Path] = [] + for dirpath, dirnames, filenames in os.walk(root, followlinks=False): + dirnames[:] = sorted(d for d in dirnames if not d.startswith(".")) + found.extend( + Path(dirpath) / name + for name in sorted(filenames) + if name.lower().endswith(".md") + ) + return found + + +def import_folder( + store: KBStore, + directory: Path, + *, + actor: str | None = None, + auto_approve: bool = True, + max_claims: int | None = None, + budget_chars: int | None = None, + min_chars: int = DEFAULT_MIN_CHARS, +) -> dict[str, Any]: + """Ingest every ``*.md`` file under ``directory``, one ingest per file. + + Unchanged files (same sha256 as the last run) and files shorter than + ``min_chars`` after whitespace-stripping are skipped. Everything else + goes through ``extract.ingest_source``: the file is registered as a + content-addressed source, receipt-backed claims are filed, and those + claims auto-approve only when ``review.auto_approve_on_receipt`` is + on. Returns a machine-readable report for the CLI to render. + + The per-row ``approved`` count is that file's ingest call -- the + receipt drain inside ``ingest_source`` is KB-wide by design, so a + straggler from an earlier file's ingest would be counted in the row + it first surfaced in. + """ + actor = actor or os.environ.get("VOUCH_AGENT") or MD_IMPORT_ACTOR + root = Path(directory).resolve() + state = _load_state(store) + rows: list[dict[str, Any]] = [] + ingested = 0 + skipped = 0 + approved_total = 0 + + for path in _iter_markdown(root): + rel = path.relative_to(root).as_posix() + resolved, data = store.read_under_root(path) + digest = sha256_hex(data) + if state.get(str(resolved)) == digest: + skipped += 1 + rows.append({"path": rel, "action": "skipped", "reason": "unchanged"}) + continue + if len(data.decode("utf-8", errors="replace").strip()) < min_chars: + skipped += 1 + rows.append({"path": rel, "action": "skipped", "reason": "too-short"}) + continue + source, approved = extract_mod.ingest_source( + store, + data, + proposed_by=actor, + title=rel, + auto_approve=auto_approve, + max_claims=max_claims, + budget_chars=budget_chars, + ) + state[str(resolved)] = source.id + ingested += 1 + approved_total += len(approved) + rows.append( + { + "path": rel, + "action": "ingested", + "source": source.id, + "approved": len(approved), + } + ) + + _save_state(store, state) + pending = sum( + 1 + for p in store.list_proposals(ProposalStatus.PENDING) + if p.kind == ProposalKind.CLAIM + ) + return { + "files": len(rows), + "ingested": ingested, + "skipped": skipped, + "approved": approved_total, + "pending_claims": pending, + "rows": rows, + } diff --git a/tests/test_md_import.py b/tests/test_md_import.py new file mode 100644 index 00000000..24e83db3 --- /dev/null +++ b/tests/test_md_import.py @@ -0,0 +1,194 @@ +"""Markdown-folder importer -- receipt-backed claims, state-tracked runs.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from vouch import md_import +from vouch.cli import cli +from vouch.models import ProposalKind, ProposalStatus +from vouch.storage import KBStore + +DOC = ( + "# acme kickoff\n\n" + "the acme-example launch moved to june. sarah-example owns the checklist.\n" + "the rollout playbook lives in the shared vault under acme/rollout.md.\n" +) + + +@pytest.fixture +def store(tmp_path: Path) -> KBStore: + s = KBStore.init(tmp_path) + (tmp_path / "vault").mkdir() + return s + + +def _note(store: KBStore, rel: str, text: str = DOC) -> Path: + path = store.root / "vault" / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return path + + +def _gate_on(store: KBStore) -> None: + store.config_path.write_text( + "review:\n auto_approve_on_receipt: true\n", encoding="utf-8" + ) + + +def _gate_off(store: KBStore) -> None: + # kb init turns the receipt gate on; write the off state explicitly so + # these tests pin the pending path no matter the repo default. + store.config_path.write_text( + "review:\n auto_approve_on_receipt: false\n", encoding="utf-8" + ) + + +def _pending_claims(store: KBStore) -> list: + return [ + p + for p in store.list_proposals(ProposalStatus.PENDING) + if p.kind == ProposalKind.CLAIM + ] + + +def test_import_registers_sources_and_files_claims(store: KBStore) -> None: + _gate_off(store) + _note(store, "acme/plan.md") + # distinct content per file: put_source is content-addressed, so two + # identical files would legitimately collapse into one source + _note( + store, + "memo.md", + "# memo\n\n" + "the infra-example migration finished without downtime on tuesday.\n" + "backups now run hourly and the retention window is fourteen days.\n", + ) + + report = md_import.import_folder(store, store.root / "vault", actor="test-actor") + + assert report["files"] == 2 + assert report["ingested"] == 2 + assert report["approved"] == 0 # gate explicitly off: nothing bypasses review + sources = store.list_sources() + assert len(sources) == 2 + pending = _pending_claims(store) + assert pending, "each file should have filed receipt-backed claims" + assert all(p.proposed_by == "test-actor" for p in pending) + + +def test_import_auto_approves_only_under_gate(store: KBStore) -> None: + _gate_on(store) + _note(store, "plan.md") + + report = md_import.import_folder(store, store.root / "vault", actor="test-actor") + + assert report["approved"] > 0 + assert _pending_claims(store) == [] + assert store.list_claims(), "approved claims must be durable" + + +def test_import_seen_state_skips_unchanged_reattempts_edited(store: KBStore) -> None: + path = _note(store, "plan.md") + + first = md_import.import_folder(store, store.root / "vault", actor="test-actor") + second = md_import.import_folder(store, store.root / "vault", actor="test-actor") + assert first["ingested"] == 1 + assert second["ingested"] == 0 + assert second["skipped"] == 1 + + path.write_text(DOC + "\nnew paragraph with more substance to it.\n", encoding="utf-8") + third = md_import.import_folder(store, store.root / "vault", actor="test-actor") + assert third["ingested"] == 1 + + +def test_import_skips_short_files_and_non_markdown(store: KBStore) -> None: + _note(store, "tiny.md", "hi") + _note(store, "note.txt") + + report = md_import.import_folder(store, store.root / "vault", actor="test-actor") + + assert report["ingested"] == 0 + assert report["files"] == 1, "only *.md files are walked" + assert report["rows"][0]["reason"] == "too-short" + assert store.list_sources() == [] + + +def test_import_recurses_and_skips_hidden_dirs(store: KBStore) -> None: + _note(store, "a/b/c.md") + _note(store, ".obsidian/config.md") + _note(store, ".git/keep.md") + + report = md_import.import_folder(store, store.root / "vault", actor="test-actor") + + assert report["files"] == 1 + assert report["rows"][0]["path"] == "a/b/c.md" + assert len(store.list_sources()) == 1 + assert store.list_sources()[0].title == "a/b/c.md" + + +def test_import_writes_state_sidecar(store: KBStore) -> None: + path = _note(store, "plan.md") + + md_import.import_folder(store, store.root / "vault", actor="test-actor") + + state_path = store.kb_dir / md_import.STATE_FILENAME + assert state_path.exists() + state = json.loads(state_path.read_text(encoding="utf-8")) + assert str(path.resolve()) in state + assert all(isinstance(v, str) and len(v) == 64 for v in state.values()) + + +def test_import_max_claims_bounds_density(store: KBStore) -> None: + _gate_off(store) + _note(store, "plan.md") + + report = md_import.import_folder( + store, store.root / "vault", actor="test-actor", max_claims=1 + ) + + assert report["ingested"] == 1 + assert len(_pending_claims(store)) == 1 + + +def test_cli_import_md(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _gate_on(store) + _note(store, "plan.md") + monkeypatch.chdir(store.root) + + result = CliRunner().invoke(cli, ["import-md", "vault"]) + + assert result.exit_code == 0, result.output + assert "1 markdown file(s)" in result.output + assert "auto-approved" in result.output + assert (store.kb_dir / md_import.STATE_FILENAME).exists() + + +def test_cli_import_md_json(store: KBStore, monkeypatch: pytest.MonkeyPatch) -> None: + _note(store, "plan.md") + monkeypatch.chdir(store.root) + + result = CliRunner().invoke(cli, ["import-md", "vault", "--json"]) + + assert result.exit_code == 0, result.output + report = json.loads(result.output) + assert report["files"] == 1 + assert report["ingested"] == 1 + assert report["rows"][0]["path"] == "plan.md" + + +def test_md_import_never_imports_approve() -> None: + import ast + import inspect + + tree = ast.parse(inspect.getsource(md_import)) + imported: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.ImportFrom) and node.module: + imported.update(f"{node.module}.{a.name}" for a in node.names) + assert "vouch.lifecycle" not in {i.rsplit(".", 1)[0] for i in imported} + assert not any(name.endswith(".approve") for name in imported) From 7889aec3bed16c82312ae021159a2841753b2e11 Mon Sep 17 00:00:00 2001 From: Steve-too Date: Sat, 8 Aug 2026 10:13:52 +0000 Subject: [PATCH 2/6] fix: resolve test_jsonl_digest_handler timebomb (pre-existing) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - future-open fixture date 2026-08-01 was now in the past, causing the followups_due assertion to include it as a third element - moved to 2028-08-01 to prevent recurrence for years - assertion changed to sorted() comparison for robustness This is not related to the md_import feature — the same test failed on clean upstream/test before any diff was applied. --- tests/test_digest.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_digest.py b/tests/test_digest.py index d61f37cf..601aa6c1 100644 --- a/tests/test_digest.py +++ b/tests/test_digest.py @@ -102,7 +102,7 @@ def followup(pid: str, due: str, status: str) -> Page: ) s.put_page(followup("due-open", "2026-07-01", "open")) - s.put_page(followup("future-open", "2026-08-01", "open")) + s.put_page(followup("future-open", "2028-08-01", "open")) s.put_page(followup("due-done", "2026-07-01", "done")) return s @@ -228,4 +228,4 @@ def test_jsonl_digest_handler(store: KBStore, monkeypatch: pytest.MonkeyPatch) - body = HANDLERS["kb.digest"]({"since": "all", "limit": 5}) assert body["pending_total"] == 2 - assert [r["id"] for r in body["followups_due"]] == ["due-open"] + assert sorted(r["id"] for r in body["followups_due"]) == ["due-open"] From c9987ae924cb973a112a1685d824d00e9354967a Mon Sep 17 00:00:00 2001 From: Steve-too Date: Sat, 8 Aug 2026 10:41:06 +0000 Subject: [PATCH 3/6] test: cover pending-claims CLI path and non-dict state guard - test_cli_import_md_no_approve_pending: --no-approve leaves claims in pending state, exercising the _echo row display (line 4792) and the 'run vouch review' reminder (line 4798) in cli.py - test_load_state_non_dict: corrupt JSON list exercises the isinstance(loaded, dict) guard at md_import.py line 62 --- tests/test_md_import.py | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/tests/test_md_import.py b/tests/test_md_import.py index 24e83db3..2cdcd75a 100644 --- a/tests/test_md_import.py +++ b/tests/test_md_import.py @@ -192,3 +192,29 @@ def test_md_import_never_imports_approve() -> None: imported.update(f"{node.module}.{a.name}" for a in node.names) assert "vouch.lifecycle" not in {i.rsplit(".", 1)[0] for i in imported} assert not any(name.endswith(".approve") for name in imported) + + +def test_cli_import_md_no_approve_pending(store, monkeypatch): + """Cover pending-claims row display (+{approved} line + 'run vouch review'). + These lines are not hit when auto_approve_on_receipt is on.""" + _gate_off(store) + _note(store, "plan.md") + monkeypatch.chdir(store.root) + + result = CliRunner().invoke(cli, ["import-md", "vault", "--no-approve"]) + + assert result.exit_code == 0, result.output + assert "plan.md" in result.output + assert "pending" in result.output + assert "run" in result.output and "vouch review" in result.output + + +def test_load_state_non_dict(tmp_path, monkeypatch): + """Corrupt state file (JSON list) triggers the non-dict return {} guard.""" + from vouch.kb import store as store_mod + + monkeypatch.chdir(tmp_path) + (tmp_path / ".vouch").mkdir(parents=True) + (tmp_path / ".vouch" / "md_import_state.json").write_text("[]", encoding="utf-8") + # Directly test _load_state — it returns {} for non-dict JSON + assert md_import._load_state(store_mod.KBStore.__new__(store_mod.KBStore)) == {} From 7bc23b2abed2457317569c50be72e478db5f9a39 Mon Sep 17 00:00:00 2001 From: Steve-too Date: Sat, 8 Aug 2026 11:02:02 +0000 Subject: [PATCH 4/6] fix: remove broken import in test_load_state_non_dict Replaced the invalid 'from vouch.kb import store as store_mod' with the existing store fixture and a corrupted state file. --- tests/test_md_import.py | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/tests/test_md_import.py b/tests/test_md_import.py index 2cdcd75a..cd91be8a 100644 --- a/tests/test_md_import.py +++ b/tests/test_md_import.py @@ -209,12 +209,13 @@ def test_cli_import_md_no_approve_pending(store, monkeypatch): assert "run" in result.output and "vouch review" in result.output -def test_load_state_non_dict(tmp_path, monkeypatch): - """Corrupt state file (JSON list) triggers the non-dict return {} guard.""" - from vouch.kb import store as store_mod - monkeypatch.chdir(tmp_path) - (tmp_path / ".vouch").mkdir(parents=True) - (tmp_path / ".vouch" / "md_import_state.json").write_text("[]", encoding="utf-8") - # Directly test _load_state — it returns {} for non-dict JSON - assert md_import._load_state(store_mod.KBStore.__new__(store_mod.KBStore)) == {} + +def test_load_state_non_dict(store, monkeypatch): + """Corrupt state file (JSON list) triggers the non-dict return {} guard.""" + monkeypatch.chdir(store.root) + state_file = md_import._state_path(store.root if hasattr(store, 'kb_dir') else store) + state_file = store.root / ".vouch" / md_import.STATE_FILENAME + state_file.parent.mkdir(parents=True, exist_ok=True) + state_file.write_text("[]", encoding="utf-8") + assert md_import._load_state(store) == {} From 88088280f8565886be723715e748365eacde553f Mon Sep 17 00:00:00 2001 From: Steve-too Date: Sat, 8 Aug 2026 12:13:18 +0000 Subject: [PATCH 5/6] fix: remove leftover broken line in test_load_state_non_dict The previous rewrite left in an old line calling _state_path with a PosixPath instead of a KBStore, causing AttributeError. Use _state_path(store) directly; drop the redundant mkdir since the store already has kb_dir. --- tests/test_md_import.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_md_import.py b/tests/test_md_import.py index cd91be8a..cfa4cde1 100644 --- a/tests/test_md_import.py +++ b/tests/test_md_import.py @@ -214,8 +214,6 @@ def test_cli_import_md_no_approve_pending(store, monkeypatch): def test_load_state_non_dict(store, monkeypatch): """Corrupt state file (JSON list) triggers the non-dict return {} guard.""" monkeypatch.chdir(store.root) - state_file = md_import._state_path(store.root if hasattr(store, 'kb_dir') else store) - state_file = store.root / ".vouch" / md_import.STATE_FILENAME - state_file.parent.mkdir(parents=True, exist_ok=True) + state_file = md_import._state_path(store) state_file.write_text("[]", encoding="utf-8") assert md_import._load_state(store) == {} From ee31f66992ffe571a84695cbbca26d25423c44b0 Mon Sep 17 00:00:00 2001 From: Steve-too Date: Sat, 8 Aug 2026 12:26:27 +0000 Subject: [PATCH 6/6] test: cover skipped-row continue path in import-md CLI Second run over unchanged notes produces rows with action=skipped, exercising the continue at cli.py:4792 (last uncovered diff line). --- tests/test_md_import.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/test_md_import.py b/tests/test_md_import.py index cfa4cde1..3574bdc6 100644 --- a/tests/test_md_import.py +++ b/tests/test_md_import.py @@ -217,3 +217,18 @@ def test_load_state_non_dict(store, monkeypatch): state_file = md_import._state_path(store) state_file.write_text("[]", encoding="utf-8") assert md_import._load_state(store) == {} + + +def test_cli_import_md_second_run_skips(store, monkeypatch): + """Second CLI run over unchanged notes hits the skipped-row continue path.""" + _gate_off(store) + _note(store, "plan.md") + monkeypatch.chdir(store.root) + + runner = CliRunner() + first = runner.invoke(cli, ["import-md", "vault", "--no-approve"]) + assert first.exit_code == 0, first.output + + second = runner.invoke(cli, ["import-md", "vault", "--no-approve"]) + assert second.exit_code == 0, second.output + assert "skipped 1" in second.output