kotonoha 0.4.0 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,493 @@
1
+ # /// script
2
+ # requires-python = "==3.12.*"
3
+ # dependencies = ["markdown-it-py>=3,<5", "PyYAML>=6,<7", "jsonschema>=4,<5"]
4
+ # ///
5
+ """Independent acceptance fixtures and focused transaction regressions."""
6
+
7
+ import copy
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import unittest
15
+ from unittest.mock import patch
16
+
17
+ import vault
18
+
19
+
20
+ ROOT = Path(__file__).resolve().parents[3]
21
+ CASES = json.loads((ROOT / "docs/obsidian/acceptance-cases.json").read_text())
22
+
23
+
24
+ class VaultTests(unittest.TestCase):
25
+ def setUp(self):
26
+ self.temporary = tempfile.TemporaryDirectory(prefix="kotonoha-obsidian-")
27
+ self.addCleanup(self.temporary.cleanup)
28
+ self.home = Path(self.temporary.name)
29
+ self.root = self.home / "vault"
30
+ self.root.mkdir()
31
+ self.workspace = self.home / "workspace"
32
+ self.workspace.mkdir(mode=0o700)
33
+ for note in CASES["fixture"]["notes"]:
34
+ self.write(note["path"], note["content"])
35
+ padding = CASES["fixture"]["padding"]
36
+ for index in range(padding["first_index"], padding["first_index"] + padding["count"]):
37
+ self.write(padding["path_format"].format(index=index),
38
+ padding["content_format"].format(index=index))
39
+ self.request = copy.deepcopy(CASES["scenario_defaults"]["request"])
40
+ self.request.update(vault_root=str(self.root), workspace=str(self.workspace))
41
+ self.request_path = self.workspace / "request.json"
42
+
43
+ def write(self, path, content):
44
+ target = self.root / path
45
+ target.parent.mkdir(parents=True, exist_ok=True)
46
+ target.write_bytes(content.encode() if isinstance(content, str) else content)
47
+
48
+ def invoke(self, command, *args):
49
+ self.request_path.write_text(json.dumps(self.request, ensure_ascii=False))
50
+ report = vault.run([command, "--request", str(self.request_path), *map(str, args)])
51
+ vault.validate(report, "Report")
52
+ return report
53
+
54
+ def snapshot(self):
55
+ return {str(path.relative_to(self.root)): path.read_bytes()
56
+ for path in self.root.rglob("*") if path.is_file() and not path.is_symlink()}
57
+
58
+ def edit_plan(self, *, review=True):
59
+ self.request.update(intent="proofread", write_target="Notes/draft.md")
60
+ before = (self.root / "Notes/draft.md").read_bytes()
61
+ after = before.replace("ではでは".encode(), "では".encode())
62
+ self.request["base_sha256"] = vault.digest(before)
63
+ self.candidate = self.workspace / "input.md"
64
+ self.candidate.write_bytes(after)
65
+ arguments = ["--candidate", self.candidate]
66
+ if review:
67
+ self.review_path = self.workspace / "review.json"
68
+ self.review_path.write_text(json.dumps({
69
+ "before_sha256": vault.digest(before), "after_sha256": vault.digest(after),
70
+ "reviewer": "Fixture reviewer, not a real semantic acceptance review",
71
+ "checks": {name: True for name in vault.CHECKS},
72
+ }))
73
+ arguments += ["--review", self.review_path]
74
+ return self.invoke("plan", *arguments)
75
+
76
+ def apply_plan(self, report):
77
+ data = report["data"]
78
+ return self.invoke("apply", "--plan", data["plan_path"],
79
+ "--approve-plan-sha256", data["plan_sha256"])
80
+
81
+ def test_exact_full_fixture_and_repeatability(self):
82
+ initial = self.snapshot()
83
+ first = self.invoke("inspect")
84
+ self.assertEqual(first["status"], "partial")
85
+ self.assertEqual(first["exit_code"], 3)
86
+ self.assertEqual(len(first["coverage"]["processed"]), 100)
87
+ self.assertEqual(first["coverage"]["unprocessed"], [])
88
+ self.assertEqual(first["data"]["links"], CASES["expected_links"])
89
+ self.assertEqual(first["data"]["observations"], CASES["expected_observations"])
90
+ self.assertEqual(first["data"]["external_url_count"], 1)
91
+ self.assertEqual(self.invoke("inspect"), first)
92
+ self.assertEqual(self.snapshot(), initial)
93
+
94
+ def test_partial_domains_and_visible_duplicates(self):
95
+ self.request["read_scopes"] = [
96
+ {"kind": "directory", "path": name} for name in ("Notes", "Targets")
97
+ ]
98
+ report = self.invoke("inspect")
99
+ links = {link["raw"]: link for link in report["data"]["links"]
100
+ if link["source_path"] == "Notes/source.md"}
101
+ for expected in CASES["partial_scope"]["overrides"]:
102
+ self.assertEqual(links[expected["raw"]]["classification"], expected["classification"])
103
+ self.assertEqual(links[expected["raw"]]["reason"], expected["reason"])
104
+ self.request["read_scopes"] = [
105
+ {"kind": "directory", "path": name} for name in ("Notes", "DupA", "DupB")
106
+ ]
107
+ report = self.invoke("inspect")
108
+ same = next(link for link in report["data"]["links"] if link["raw"] == "[[Same]]")
109
+ self.assertEqual(same["classification"], "ambiguous")
110
+ self.assertEqual(same["candidates"], ["DupA/Same.md", "DupB/Same.md"])
111
+
112
+ def test_masked_examples(self):
113
+ self.request["read_scopes"] = [{"kind": "file", "path": "Notes/masks.md"}]
114
+ report = self.invoke("inspect")
115
+ self.assertEqual((report["status"], report["exit_code"]), ("completed", 0))
116
+ self.assertEqual(report["data"]["links"], [])
117
+
118
+ def test_inline_code_cannot_consume_other_blocks(self):
119
+ for text in (
120
+ "# T\n\nUse `a stray tick.\n\nSee [[Missing]] here.\n\nAnother ` tick.\n",
121
+ "# T\n\nStray ` tick.\n\n```md\ncode\n```\n\n[[Missing]]\n\nAnother ` tick.\n",
122
+ "# T\n\nStray ` tick.\n\n## Heading\n\n[[Missing]]\n\nAnother ` tick.\n",
123
+ ):
124
+ with self.subTest(text=text):
125
+ note = vault.parse_note("note.md", text.encode())
126
+ self.assertFalse(note["fatal"])
127
+ self.assertEqual([link["raw"] for link in note["links"]], ["[[Missing]]"])
128
+ valid = vault.parse_note("note.md", b"# T\n\n`inline\n[[CodeExample]]`\n\n[[Visible]]\n")
129
+ self.assertEqual([link["raw"] for link in valid["links"]], ["[[Visible]]"])
130
+ self.write("Targets/Unique.md", "# Unique\n\nStray ` tick.\n\n## 詳細\n\nAnother ` tick.\n")
131
+ report = self.invoke("inspect")
132
+ link = next(link for link in report["data"]["links"] if link["raw"] == "[[Targets/Unique#詳細]]")
133
+ self.assertEqual(link["reason"], "heading_found")
134
+
135
+ def test_inline_masks_cannot_invent_indented_code_blocks(self):
136
+ for text in (
137
+ "`x` [[Z]]\n",
138
+ "# T\n\n`code` and [[Z]]\n",
139
+ "`a\nb [[Y]] c` [[Z]]\n",
140
+ "%% comment %% [[Z]]\n",
141
+ "%% comment\ncontinued %% [[Z]]\n",
142
+ ):
143
+ with self.subTest(text=text):
144
+ note = vault.parse_note("note.md", text.encode())
145
+ self.assertFalse(note["fatal"])
146
+ self.assertEqual([link["raw"] for link in note["links"]], ["[[Z]]"])
147
+ note = vault.parse_note("note.md", b"- item `a\n- `b` [[L]]\n")
148
+ self.assertEqual([link["raw"] for link in note["links"]], ["[[L]]"])
149
+
150
+ def test_concrete_parse_and_precedence_arrangements(self):
151
+ for name in ("unclosed_frontmatter", "duplicate_yaml_key_rejected",
152
+ "nested_yaml_rejected", "syntax_before_scope",
153
+ "encoding_before_scope", "unmatched_inline_backtick"):
154
+ with self.subTest(name=name):
155
+ arrangement = CASES["arrangements"][name]
156
+ path = arrangement["replace_note"]["path"]
157
+ original = (self.root / path).read_bytes()
158
+ self.write(path, arrangement["replace_note"]["content"])
159
+ report = self.invoke("inspect")
160
+ self.write(path, original)
161
+ expected = arrangement["expected"]
162
+ for key in ("status", "exit_code"):
163
+ if key in expected:
164
+ self.assertEqual(report[key], expected[key])
165
+ if "parses_complete" in expected:
166
+ self.assertEqual(report["coverage"]["parses_complete"], expected["parses_complete"])
167
+ if "issue_codes_contains" in expected:
168
+ self.assertTrue(set(expected["issue_codes_contains"]) <= {item["code"] for item in report["issues"]})
169
+ link_expected = arrangement.get("expected_link")
170
+ if link_expected:
171
+ actual = next(link for link in report["data"]["links"]
172
+ if link["raw"] == link_expected["raw"])
173
+ for key, value in link_expected.items():
174
+ self.assertEqual(actual[key], value)
175
+ if name == "unclosed_frontmatter":
176
+ for raw in ("[[Unique]]", "[[Targets/Unique]]", "[[Targets/Unique#詳細]]"):
177
+ actual = next(link for link in report["data"]["links"] if link["raw"] == raw)
178
+ self.assertEqual(actual["reason"], "target_parse_incomplete")
179
+
180
+ def test_fatal_source_does_not_fabricate_links(self):
181
+ self.write("Notes/source.md", "# Source\n\n%% [[UnclosedComment]]\n")
182
+ report = self.invoke("inspect")
183
+ self.assertFalse(report["coverage"]["parses_complete"])
184
+ self.assertFalse(any(link["source_path"] == "Notes/source.md"
185
+ for link in report["data"]["links"]))
186
+
187
+ def test_read_failure_and_symlink_invalidate_negative_evidence(self):
188
+ original = vault.Vault.read
189
+
190
+ def fail(instance, path, maximum=None):
191
+ if path == "Targets/Unique.md":
192
+ raise PermissionError()
193
+ return original(instance, path, maximum)
194
+
195
+ with patch.object(vault.Vault, "read", fail):
196
+ report = self.invoke("inspect")
197
+ self.assertFalse(report["coverage"]["reads_complete"])
198
+ for raw in ("[[Unique]]", "[[Targets/Unique]]"):
199
+ link = next(link for link in report["data"]["links"] if link["raw"] == raw)
200
+ self.assertEqual(link["reason"], "scope_incomplete")
201
+ outside = self.home / "secret.md"
202
+ outside.write_text("Secret must not be read")
203
+ (self.root / "Ghost.md").symlink_to(outside)
204
+ report = self.invoke("inspect")
205
+ ghost = next(link for link in report["data"]["links"] if link["raw"] == "[[Ghost]]")
206
+ self.assertEqual(ghost["reason"], "scope_incomplete")
207
+ denied = self.invoke("read", "--path", "Ghost.md")
208
+ self.assertEqual(denied["exit_code"], 2)
209
+
210
+ def test_limits_and_consent(self):
211
+ self.request["read_scopes"] = [{"kind": "directory", "path": "Padding"}]
212
+ self.request["limits"]["max_files"] = 2
213
+ report = self.invoke("inspect")
214
+ self.assertEqual(report["coverage"]["processed"], ["Padding/note-001.md", "Padding/note-002.md"])
215
+ self.assertEqual(len(report["coverage"]["unprocessed"]), 78)
216
+ self.assertEqual(report["exit_code"], 3)
217
+ self.request["consent"]["model_context"] = False
218
+ with patch.object(vault.Vault, "read", side_effect=AssertionError("Unauthorized read")):
219
+ self.assertEqual(self.invoke("inspect")["exit_code"], 2)
220
+
221
+ def test_unsupported_metadata_and_non_utf8_are_explicit(self):
222
+ (self.root / "Notes/alias.md").hardlink_to(self.root / "Notes/draft.md")
223
+ blocked = self.edit_plan()
224
+ self.assertEqual(blocked["exit_code"], 4)
225
+ self.assertEqual(blocked["issues"][0]["code"], "capability_missing")
226
+ self.write("Notes/invalid.md", b"\xff")
227
+ self.assertEqual(self.invoke("read", "--path", "Notes/invalid.md")["issues"][0]["code"],
228
+ "unsupported_format")
229
+ self.assertEqual(self.invoke("read", "--path", "Notes/missing.md")["issues"][0]["code"],
230
+ "read_failed")
231
+
232
+ def test_completed_note_variables_and_unauthorized_writes(self):
233
+ self.request.update(intent="create", write_target="Notes/new.md", deliverable="note")
234
+ candidate = self.workspace / "template.md"
235
+ candidate.write_text("# {{title}}\n")
236
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["issues"][0]["code"],
237
+ "gate_blocked")
238
+ self.request["deliverable"] = "template"
239
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["exit_code"], 0)
240
+ for target in ("../outside.md", ".obsidian/private.md", "Notes/plain.txt"):
241
+ self.request["write_target"] = target
242
+ before = self.snapshot()
243
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["exit_code"], 2)
244
+ self.assertEqual(self.snapshot(), before)
245
+
246
+ def test_approved_edit_replay_and_external_change(self):
247
+ initial = self.snapshot()
248
+ planned = self.edit_plan()
249
+ self.assertEqual(planned["status"], "completed")
250
+ self.assertEqual(self.snapshot(), initial)
251
+ saved = self.apply_plan(planned)
252
+ self.assertEqual((saved["status"], saved["write_status"]), ("completed", "saved"))
253
+ self.assertTrue(saved["data"]["changed"])
254
+ current = self.snapshot()
255
+ self.assertEqual([name for name in current if current[name] != initial[name]], ["Notes/draft.md"])
256
+ replayed = self.apply_plan(planned)
257
+ self.assertFalse(replayed["data"]["changed"])
258
+ self.assertEqual(replayed["issues"][0]["code"], "already_applied")
259
+ self.write("Notes/draft.md", initial["Notes/draft.md"])
260
+ blocked = self.apply_plan(planned)
261
+ self.assertEqual(blocked["exit_code"], 2)
262
+ self.assertEqual(blocked["issues"][0]["code"], "target_changed")
263
+ self.assertEqual((self.root / "Notes/draft.md").read_bytes(), initial["Notes/draft.md"])
264
+
265
+ def test_noop_and_missing_semantic_review(self):
266
+ result = self.edit_plan(review=False)
267
+ self.assertEqual(result["exit_code"], 2)
268
+ self.assertEqual(result["issues"][0]["code"], "semantic_review_required")
269
+ self.candidate.write_bytes((self.root / "Notes/draft.md").read_bytes())
270
+ result = self.invoke("plan", "--candidate", self.candidate)
271
+ self.assertEqual(result["exit_code"], 0)
272
+ self.assertTrue(result["data"]["no_changes"])
273
+ self.assertIsNone(result["data"]["plan_path"])
274
+ (self.root / "Notes/linked.md").hardlink_to(self.root / "Notes/draft.md")
275
+ with patch.object(vault, "write_capabilities", side_effect=AssertionError("No write needed")):
276
+ self.assertTrue(self.invoke("plan", "--candidate", self.candidate)["data"]["no_changes"])
277
+
278
+ def test_plan_respects_explicit_byte_limit(self):
279
+ self.edit_plan()
280
+ self.request["limits"]["max_file_bytes"] = 1
281
+ result = self.invoke("plan", "--candidate", self.candidate, "--review", self.review_path)
282
+ self.assertEqual(result["exit_code"], 2)
283
+ self.assertEqual(result["issues"][0]["code"], "limit_reached")
284
+
285
+ def test_stale_original_approval_and_stage_conflict(self):
286
+ planned = self.edit_plan()
287
+ original = (self.root / "Notes/draft.md").read_bytes()
288
+ self.write("Notes/draft.md", original + b"external")
289
+ self.assertEqual(self.apply_plan(planned)["issues"][0]["code"], "target_changed")
290
+ self.write("Notes/draft.md", original)
291
+ bad = self.invoke("apply", "--plan", planned["data"]["plan_path"],
292
+ "--approve-plan-sha256", "0" * 64)
293
+ self.assertEqual(bad["issues"][0]["code"], "plan_not_approved")
294
+ plan = json.loads(Path(planned["data"]["plan_path"]).read_bytes())
295
+ self.write(plan["stage_target"], "Keep existing stage")
296
+ blocked = self.apply_plan(planned)
297
+ self.assertEqual(blocked["issues"][0]["code"], "target_exists")
298
+ self.assertEqual((self.root / plan["stage_target"]).read_text(), "Keep existing stage")
299
+
300
+ def test_parent_identity_change(self):
301
+ planned = self.edit_plan()
302
+ (self.root / "Notes").rename(self.root / "OldNotes")
303
+ (self.root / "Notes").mkdir()
304
+ self.write("Notes/draft.md", (self.root / "OldNotes/draft.md").read_bytes())
305
+ result = self.apply_plan(planned)
306
+ self.assertEqual(result["issues"][0]["code"], "parent_changed")
307
+
308
+ def test_protected_mutations_block_even_with_attestation(self):
309
+ fixture = CASES["protected_edit_fixture"]
310
+ before = fixture["content"].encode()
311
+ for mutation in fixture["mutations"]:
312
+ with self.subTest(case=mutation["case"]):
313
+ after = before.replace(*(value.encode() for value in mutation["replace"]))
314
+ review = {
315
+ "before_sha256": vault.digest(before), "after_sha256": vault.digest(after),
316
+ "reviewer": "Artificial fixture; not a semantic guarantee",
317
+ "checks": {key: True for key in vault.CHECKS},
318
+ }
319
+ with self.assertRaises(vault.Failure) as blocked:
320
+ vault.edit_gate(before, after, [], review)
321
+ self.assertEqual(blocked.exception.code, "gate_blocked")
322
+
323
+ def test_create_and_conflict_without_replacement(self):
324
+ self.request.update(intent="create", write_target="Notes/new.md", deliverable="note")
325
+ candidate = self.workspace / "new.md"
326
+ candidate.write_text("# 作成ノート\n\n2026-10-19\n")
327
+ planned = self.invoke("plan", "--candidate", candidate)
328
+ self.assertEqual(planned["exit_code"], 0)
329
+ saved = self.apply_plan(planned)
330
+ self.assertEqual(saved["exit_code"], 0)
331
+ self.assertEqual((self.root / "Notes/new.md").read_bytes(), candidate.read_bytes())
332
+ mask = os.umask(0)
333
+ os.umask(mask)
334
+ self.assertEqual((self.root / "Notes/new.md").stat().st_mode & 0o777, 0o666 & ~mask)
335
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["issues"][0]["code"], "target_exists")
336
+
337
+ def test_precommit_and_postcommit_failure(self):
338
+ planned = self.edit_plan()
339
+ original = (self.root / "Notes/draft.md").read_bytes()
340
+ replace = os.replace
341
+
342
+ def fail_commit(source, target, **kwargs):
343
+ if "src_dir_fd" in kwargs:
344
+ raise OSError("Injected precommit failure")
345
+ return replace(source, target, **kwargs)
346
+
347
+ with patch.object(vault.os, "replace", fail_commit):
348
+ result = self.apply_plan(planned)
349
+ self.assertEqual((result["status"], result["write_status"]), ("failed", "unsaved"))
350
+ self.assertEqual((self.root / "Notes/draft.md").read_bytes(), original)
351
+ self.assertFalse(list((self.root / "Notes").glob(".kotonoha-*")))
352
+ actual = vault.replace_private
353
+
354
+ def fail_receipt(path, data):
355
+ if path.name == "receipt.json":
356
+ raise OSError("Injected postcommit failure")
357
+ return actual(path, data)
358
+
359
+ with patch.object(vault, "replace_private", fail_receipt):
360
+ result = self.apply_plan(planned)
361
+ self.assertEqual((result["status"], result["write_status"]), ("failed", "unknown"))
362
+ self.assertNotEqual((self.root / "Notes/draft.md").read_bytes(), original)
363
+ self.assertEqual(self.apply_plan(planned)["issues"][0]["code"], "commit_outcome_uncertain")
364
+
365
+ def test_cli_shapes_and_control_failures(self):
366
+ self.request_path.write_text(json.dumps(self.request))
367
+ result = subprocess.run(
368
+ [sys.executable, str(Path(vault.__file__)), "read", "--request",
369
+ str(self.request_path), "--path", "Notes/draft.md"],
370
+ capture_output=True, text=True,
371
+ )
372
+ self.assertEqual(result.returncode, 0, result.stderr)
373
+ self.assertEqual(json.loads(result.stdout)["data"]["path"], "Notes/draft.md")
374
+ self.assertEqual(vault.run(["apply"])["exit_code"], 1)
375
+ self.request_path.write_text('{"schema_version":1,"schema_version":1}')
376
+ self.assertEqual(vault.run(["inspect", "--request", str(self.request_path)])["exit_code"], 1)
377
+ self.assertEqual(self.invoke("read", "--path", "../escape.md")["exit_code"], 2)
378
+ self.assertEqual(self.invoke("read", "--path", ".obsidian/private.md")["exit_code"], 2)
379
+
380
+ def test_decorated_heading_never_becomes_plain_after_masking(self):
381
+ for heading in ("`code`", "Text %% comment %%", "==marked==", r"escaped\_text", "&amp;"):
382
+ with self.subTest(heading=heading):
383
+ self.write("Targets/Unique.md", "# Unique\n\n## " + heading + "\n")
384
+ report = self.invoke("inspect")
385
+ missing = next(link for link in report["data"]["links"]
386
+ if link["raw"] == "[[Targets/Unique#Missing]]")
387
+ self.assertEqual(missing["reason"], "unsupported_heading")
388
+ self.assertNotEqual(missing["classification"], "unresolved")
389
+
390
+ def test_nested_markdown_labels_are_protected(self):
391
+ before = b"# Test\n\n[a [b] c](https://example.com/path)\n"
392
+ after = before.replace(b"[b]", b"[d]")
393
+ review = {"before_sha256": vault.digest(before), "after_sha256": vault.digest(after),
394
+ "reviewer": "Artificial fixture", "checks": {key: True for key in vault.CHECKS}}
395
+ with self.assertRaises(vault.Failure) as blocked:
396
+ vault.edit_gate(before, after, [], review)
397
+ self.assertEqual(blocked.exception.code, "gate_blocked")
398
+
399
+ def test_escaped_link_examples_and_no_final_newline_diff(self):
400
+ self.write("Notes/source.md", "# Source\n\n\\[[Example]]\n\\[x](missing.md)\n[[Targets/Unique]]\n")
401
+ report = self.invoke("inspect")
402
+ links = [link for link in report["data"]["links"] if link["source_path"] == "Notes/source.md"]
403
+ self.assertEqual([link["raw"] for link in links], ["[[Targets/Unique]]"])
404
+ diff = vault.diff_bytes(b"before", b"after", "note.md").decode()
405
+ self.assertIn("-before\n\\n", diff)
406
+ self.assertIn("+after\n\\n", diff)
407
+
408
+ def test_create_race_does_not_replace_external_file(self):
409
+ self.request.update(intent="create", write_target="Notes/new.md", deliverable="note")
410
+ candidate = self.workspace / "new.md"
411
+ candidate.write_text("# New\n")
412
+ planned = self.invoke("plan", "--candidate", candidate)
413
+ create = vault.exclusive_create
414
+
415
+ def race(parent, stage_name, name):
416
+ self.write("Notes/new.md", b"External winner\n")
417
+ return create(parent, stage_name, name)
418
+
419
+ with patch.object(vault, "exclusive_create", race):
420
+ blocked = self.apply_plan(planned)
421
+ self.assertEqual((blocked["status"], blocked["exit_code"]), ("not_performed", 2))
422
+ self.assertEqual((self.root / "Notes/new.md").read_bytes(), b"External winner\n")
423
+ self.assertFalse(list((self.root / "Notes").glob(".kotonoha-*")))
424
+
425
+ def test_stale_base_and_hash_bound_review(self):
426
+ self.edit_plan()
427
+ original = (self.root / "Notes/draft.md").read_bytes()
428
+ self.write("Notes/draft.md", original + b"External change\n")
429
+ result = self.invoke("plan", "--candidate", self.candidate, "--review", self.review_path)
430
+ self.assertEqual(result["issues"][0]["code"], "target_changed")
431
+ self.write("Notes/draft.md", original)
432
+ review = json.loads(self.review_path.read_bytes())
433
+ for alteration in ({"after_sha256": "0" * 64},
434
+ {"checks": {**review["checks"], "actors": False}},
435
+ {"checks": {**review["checks"], "actors": "true"}}):
436
+ with self.subTest(alteration=alteration):
437
+ self.review_path.write_text(json.dumps(review | alteration))
438
+ result = self.invoke("plan", "--candidate", self.candidate, "--review", self.review_path)
439
+ self.assertEqual(result["issues"][0]["code"], "semantic_review_required")
440
+
441
+ def test_receipt_missing_or_invalid_does_not_trigger_second_write(self):
442
+ planned = self.edit_plan()
443
+ self.assertEqual(self.apply_plan(planned)["exit_code"], 0)
444
+ operation = Path(planned["data"]["plan_path"]).parent
445
+ receipt = operation / "receipt.json"
446
+ receipt.unlink()
447
+ result = self.apply_plan(planned)
448
+ self.assertEqual(result["issues"][0]["code"], "commit_outcome_uncertain")
449
+ receipt.write_text('{"verified": false}')
450
+ self.assertEqual(self.apply_plan(planned)["issues"][0]["code"], "commit_outcome_uncertain")
451
+
452
+ def test_concurrent_actual_cli_serializes_same_operation(self):
453
+ planned = self.edit_plan()
454
+ arguments = [sys.executable, vault.__file__, "apply", "--request", str(self.request_path),
455
+ "--plan", planned["data"]["plan_path"],
456
+ "--approve-plan-sha256", planned["data"]["plan_sha256"]]
457
+ children = [subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
458
+ text=True) for _ in range(2)]
459
+ results = []
460
+ for child in children:
461
+ stdout, stderr = child.communicate(timeout=30)
462
+ self.assertEqual(child.returncode, 0, stderr)
463
+ results.append(json.loads(stdout))
464
+ self.assertEqual(sorted(result["data"]["changed"] for result in results), [False, True])
465
+ self.assertTrue(all(result["data"]["verified"] for result in results))
466
+
467
+ def test_installed_offline_launcher_and_single_skill_dependency_failure(self):
468
+ installed = self.home / "installed skills"
469
+ subprocess.run(["node", str(ROOT / "bin/kotonoha.js"), "install", "--target", str(installed)],
470
+ check=True, capture_output=True)
471
+ executable = installed / "obsidian-vault/scripts/vault.py"
472
+ self.request_path.write_text(json.dumps(self.request))
473
+ command = ["uv", "run", "--offline", "--locked", "--python", "3.12", str(executable)]
474
+ result = subprocess.run(command + ["inspect", "--request", str(self.request_path)],
475
+ capture_output=True, text=True)
476
+ self.assertEqual(result.returncode, 3, result.stderr)
477
+ self.assertEqual(json.loads(result.stdout)["data"]["links"], CASES["expected_links"])
478
+ single = self.home / "single"
479
+ subprocess.run(["node", str(ROOT / "bin/kotonoha.js"), "install", "--skill", "obsidian-vault",
480
+ "--target", str(single)], check=True, capture_output=True)
481
+ self.edit_plan()
482
+ result = subprocess.run(
483
+ ["uv", "run", "--offline", "--locked", "--python", "3.12",
484
+ str(single / "obsidian-vault/scripts/vault.py"), "plan", "--request",
485
+ str(self.request_path), "--candidate", str(self.candidate),
486
+ "--review", str(self.review_path)], capture_output=True, text=True,
487
+ )
488
+ self.assertEqual(result.returncode, 4, result.stderr)
489
+ self.assertEqual(json.loads(result.stdout)["issues"][0]["code"], "dependency_missing")
490
+
491
+
492
+ if __name__ == "__main__":
493
+ unittest.main()