kotonoha 0.4.0 → 0.4.2

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.
Files changed (38) hide show
  1. package/CHANGELOG.md +47 -1
  2. package/README-ja.md +82 -4
  3. package/README.md +123 -6
  4. package/bin/kotonoha.js +4 -1
  5. package/package.json +8 -3
  6. package/skills/japanese-prose/scripts/invariants.py +24 -6
  7. package/skills/obsidian-bases/SKILL.md +140 -0
  8. package/skills/obsidian-bases/assets/templates/cards.base +8 -0
  9. package/skills/obsidian-bases/assets/templates/kanban.base +7 -0
  10. package/skills/obsidian-bases/assets/templates/list.base +8 -0
  11. package/skills/obsidian-bases/assets/templates/table.base +10 -0
  12. package/skills/obsidian-bases/references/execution-contract.json +246 -0
  13. package/skills/obsidian-bases/references/workflow.md +155 -0
  14. package/skills/obsidian-bases/scripts/check.cjs +690 -0
  15. package/skills/obsidian-bases/scripts/package-lock.json +104 -0
  16. package/skills/obsidian-bases/scripts/package.json +18 -0
  17. package/skills/obsidian-bases/scripts/verify.cjs +454 -0
  18. package/skills/obsidian-canvas/SKILL.md +134 -0
  19. package/skills/obsidian-canvas/assets/templates/comparison.canvas +11 -0
  20. package/skills/obsidian-canvas/assets/templates/mind-map.canvas +11 -0
  21. package/skills/obsidian-canvas/assets/templates/note-map.canvas +10 -0
  22. package/skills/obsidian-canvas/assets/templates/workflow.canvas +11 -0
  23. package/skills/obsidian-canvas/references/execution-contract.json +404 -0
  24. package/skills/obsidian-canvas/references/workflow.md +187 -0
  25. package/skills/obsidian-canvas/scripts/canvas.py +1479 -0
  26. package/skills/obsidian-canvas/scripts/canvas.py.lock +143 -0
  27. package/skills/obsidian-canvas/scripts/test_canvas.py +2047 -0
  28. package/skills/obsidian-canvas/scripts/test_canvas.py.lock +143 -0
  29. package/skills/obsidian-vault/SKILL.md +162 -0
  30. package/skills/obsidian-vault/references/execution-contract.json +479 -0
  31. package/skills/obsidian-vault/references/workflow.md +206 -0
  32. package/skills/obsidian-vault/scripts/test_vault.py +569 -0
  33. package/skills/obsidian-vault/scripts/test_vault.py.lock +143 -0
  34. package/skills/obsidian-vault/scripts/vault.py +1363 -0
  35. package/skills/obsidian-vault/scripts/vault.py.lock +143 -0
  36. package/skills/tech-writer/SKILL.md +26 -7
  37. package/skills/tech-writer/assets/templates/obsidian.md +37 -0
  38. package/skills/tech-writer/references/doctypes/obsidian.md +106 -0
@@ -0,0 +1,569 @@
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_shared_storage_keeps_markdown_facade_and_scope_checks(self):
95
+ self.write("Maps/source.canvas", '{"nodes":[]}\n')
96
+ self.write("Assets/picture.png", b"opaque attachment")
97
+ (self.root / "Alias.png").symlink_to("Assets/picture.png")
98
+ storage = vault.Vault(self.request)
99
+ try:
100
+ self.assertEqual(storage.read_authorized("Maps/source.canvas")[0], b'{"nodes":[]}\n')
101
+ with self.assertRaises(vault.Failure):
102
+ storage.read("Maps/source.canvas")
103
+ paths, _, _, _ = storage.enumerate_authorized((".canvas", ".md"))
104
+ self.assertIn("Maps/source.canvas", paths)
105
+ self.assertNotIn("Maps/source.canvas", storage.enumerate()[0])
106
+ self.assertEqual(storage.stat_authorized("Assets/picture.png").st_size, 17)
107
+ with self.assertRaises(vault.Failure) as caught:
108
+ storage.stat_authorized("Alias.png")
109
+ self.assertEqual(caught.exception.code, "symlink_not_followed")
110
+ finally:
111
+ storage.close()
112
+ limited = dict(self.request, read_scopes=[{"kind": "file", "path": "Maps/source.canvas"}])
113
+ storage = vault.Vault(limited)
114
+ try:
115
+ with self.assertRaises(vault.Failure) as caught:
116
+ storage.stat_authorized("Assets/picture.png")
117
+ self.assertEqual(caught.exception.code, "outside_scope")
118
+ self.assertEqual(storage.enumerate_authorized((".md",))[0], [])
119
+ with self.assertRaises(vault.Failure):
120
+ storage.enumerate()
121
+ finally:
122
+ storage.close()
123
+
124
+ def test_shared_commit_retains_progress_after_failed_verification(self):
125
+ self.write("Maps/.keep.md", "# Keep\n")
126
+ storage = vault.Vault(self.request)
127
+ self.addCleanup(storage.close)
128
+ progress = {}
129
+ with storage.parent("Maps/new.canvas") as (parent, name):
130
+ def bad_read(parent, name):
131
+ return b"not the candidate", os.stat(name, dir_fd=parent)
132
+
133
+ with self.assertRaises(OSError):
134
+ vault.commit_bytes(
135
+ storage, parent, name, ".owned-stage", None, b'{"nodes":[]}\n',
136
+ None, vault.identity(os.fstat(storage.fd)), progress,
137
+ read_at=bad_read, target="Maps/new.canvas",
138
+ )
139
+ self.assertTrue(progress["committed"])
140
+ self.assertFalse(progress["own_stage"])
141
+ self.assertEqual((self.root / "Maps/new.canvas").read_bytes(), b'{"nodes":[]}\n')
142
+ self.assertFalse((self.root / "Maps/.owned-stage").exists())
143
+
144
+ def test_failed_stage_cleanup_keeps_pending_legacy_replay_guard(self):
145
+ planned = self.edit_plan()
146
+ before = (self.root / "Notes/draft.md").read_bytes()
147
+ replace, unlink = vault.os.replace, vault.os.unlink
148
+
149
+ def fail_commit(source, target, **kwargs):
150
+ if isinstance(source, str) and source.startswith(".kotonoha-"):
151
+ raise OSError("Injected precommit failure")
152
+ return replace(source, target, **kwargs)
153
+
154
+ def fail_cleanup(path, **kwargs):
155
+ if isinstance(path, str) and path.startswith(".kotonoha-"):
156
+ raise PermissionError("Injected owned-stage cleanup failure")
157
+ return unlink(path, **kwargs)
158
+
159
+ with patch.object(vault.os, "replace", fail_commit), patch.object(vault.os, "unlink", fail_cleanup):
160
+ failed = self.apply_plan(planned)
161
+ self.assertEqual((failed["status"], failed["exit_code"], failed["write_status"]),
162
+ ("failed", 1, "unsaved"))
163
+ operation = Path(planned["data"]["plan_path"]).parent
164
+ self.assertEqual(json.loads((operation / "result.json").read_text())["write_status"], "unknown")
165
+ self.assertEqual((self.root / "Notes/draft.md").read_bytes(), before)
166
+ repeated = self.apply_plan(planned)
167
+ self.assertEqual(repeated["exit_code"], 2)
168
+ self.assertEqual(repeated["issues"][0]["code"], "commit_outcome_uncertain")
169
+
170
+ def test_partial_domains_and_visible_duplicates(self):
171
+ self.request["read_scopes"] = [
172
+ {"kind": "directory", "path": name} for name in ("Notes", "Targets")
173
+ ]
174
+ report = self.invoke("inspect")
175
+ links = {link["raw"]: link for link in report["data"]["links"]
176
+ if link["source_path"] == "Notes/source.md"}
177
+ for expected in CASES["partial_scope"]["overrides"]:
178
+ self.assertEqual(links[expected["raw"]]["classification"], expected["classification"])
179
+ self.assertEqual(links[expected["raw"]]["reason"], expected["reason"])
180
+ self.request["read_scopes"] = [
181
+ {"kind": "directory", "path": name} for name in ("Notes", "DupA", "DupB")
182
+ ]
183
+ report = self.invoke("inspect")
184
+ same = next(link for link in report["data"]["links"] if link["raw"] == "[[Same]]")
185
+ self.assertEqual(same["classification"], "ambiguous")
186
+ self.assertEqual(same["candidates"], ["DupA/Same.md", "DupB/Same.md"])
187
+
188
+ def test_masked_examples(self):
189
+ self.request["read_scopes"] = [{"kind": "file", "path": "Notes/masks.md"}]
190
+ report = self.invoke("inspect")
191
+ self.assertEqual((report["status"], report["exit_code"]), ("completed", 0))
192
+ self.assertEqual(report["data"]["links"], [])
193
+
194
+ def test_inline_code_cannot_consume_other_blocks(self):
195
+ for text in (
196
+ "# T\n\nUse `a stray tick.\n\nSee [[Missing]] here.\n\nAnother ` tick.\n",
197
+ "# T\n\nStray ` tick.\n\n```md\ncode\n```\n\n[[Missing]]\n\nAnother ` tick.\n",
198
+ "# T\n\nStray ` tick.\n\n## Heading\n\n[[Missing]]\n\nAnother ` tick.\n",
199
+ ):
200
+ with self.subTest(text=text):
201
+ note = vault.parse_note("note.md", text.encode())
202
+ self.assertFalse(note["fatal"])
203
+ self.assertEqual([link["raw"] for link in note["links"]], ["[[Missing]]"])
204
+ valid = vault.parse_note("note.md", b"# T\n\n`inline\n[[CodeExample]]`\n\n[[Visible]]\n")
205
+ self.assertEqual([link["raw"] for link in valid["links"]], ["[[Visible]]"])
206
+ self.write("Targets/Unique.md", "# Unique\n\nStray ` tick.\n\n## 詳細\n\nAnother ` tick.\n")
207
+ report = self.invoke("inspect")
208
+ link = next(link for link in report["data"]["links"] if link["raw"] == "[[Targets/Unique#詳細]]")
209
+ self.assertEqual(link["reason"], "heading_found")
210
+
211
+ def test_inline_masks_cannot_invent_indented_code_blocks(self):
212
+ for text in (
213
+ "`x` [[Z]]\n",
214
+ "# T\n\n`code` and [[Z]]\n",
215
+ "`a\nb [[Y]] c` [[Z]]\n",
216
+ "%% comment %% [[Z]]\n",
217
+ "%% comment\ncontinued %% [[Z]]\n",
218
+ ):
219
+ with self.subTest(text=text):
220
+ note = vault.parse_note("note.md", text.encode())
221
+ self.assertFalse(note["fatal"])
222
+ self.assertEqual([link["raw"] for link in note["links"]], ["[[Z]]"])
223
+ note = vault.parse_note("note.md", b"- item `a\n- `b` [[L]]\n")
224
+ self.assertEqual([link["raw"] for link in note["links"]], ["[[L]]"])
225
+
226
+ def test_concrete_parse_and_precedence_arrangements(self):
227
+ for name in ("unclosed_frontmatter", "duplicate_yaml_key_rejected",
228
+ "nested_yaml_rejected", "syntax_before_scope",
229
+ "encoding_before_scope", "unmatched_inline_backtick"):
230
+ with self.subTest(name=name):
231
+ arrangement = CASES["arrangements"][name]
232
+ path = arrangement["replace_note"]["path"]
233
+ original = (self.root / path).read_bytes()
234
+ self.write(path, arrangement["replace_note"]["content"])
235
+ report = self.invoke("inspect")
236
+ self.write(path, original)
237
+ expected = arrangement["expected"]
238
+ for key in ("status", "exit_code"):
239
+ if key in expected:
240
+ self.assertEqual(report[key], expected[key])
241
+ if "parses_complete" in expected:
242
+ self.assertEqual(report["coverage"]["parses_complete"], expected["parses_complete"])
243
+ if "issue_codes_contains" in expected:
244
+ self.assertTrue(set(expected["issue_codes_contains"]) <= {item["code"] for item in report["issues"]})
245
+ link_expected = arrangement.get("expected_link")
246
+ if link_expected:
247
+ actual = next(link for link in report["data"]["links"]
248
+ if link["raw"] == link_expected["raw"])
249
+ for key, value in link_expected.items():
250
+ self.assertEqual(actual[key], value)
251
+ if name == "unclosed_frontmatter":
252
+ for raw in ("[[Unique]]", "[[Targets/Unique]]", "[[Targets/Unique#詳細]]"):
253
+ actual = next(link for link in report["data"]["links"] if link["raw"] == raw)
254
+ self.assertEqual(actual["reason"], "target_parse_incomplete")
255
+
256
+ def test_fatal_source_does_not_fabricate_links(self):
257
+ self.write("Notes/source.md", "# Source\n\n%% [[UnclosedComment]]\n")
258
+ report = self.invoke("inspect")
259
+ self.assertFalse(report["coverage"]["parses_complete"])
260
+ self.assertFalse(any(link["source_path"] == "Notes/source.md"
261
+ for link in report["data"]["links"]))
262
+
263
+ def test_read_failure_and_symlink_invalidate_negative_evidence(self):
264
+ original = vault.Vault.read
265
+
266
+ def fail(instance, path, maximum=None):
267
+ if path == "Targets/Unique.md":
268
+ raise PermissionError()
269
+ return original(instance, path, maximum)
270
+
271
+ with patch.object(vault.Vault, "read", fail):
272
+ report = self.invoke("inspect")
273
+ self.assertFalse(report["coverage"]["reads_complete"])
274
+ for raw in ("[[Unique]]", "[[Targets/Unique]]"):
275
+ link = next(link for link in report["data"]["links"] if link["raw"] == raw)
276
+ self.assertEqual(link["reason"], "scope_incomplete")
277
+ outside = self.home / "secret.md"
278
+ outside.write_text("Secret must not be read")
279
+ (self.root / "Ghost.md").symlink_to(outside)
280
+ report = self.invoke("inspect")
281
+ ghost = next(link for link in report["data"]["links"] if link["raw"] == "[[Ghost]]")
282
+ self.assertEqual(ghost["reason"], "scope_incomplete")
283
+ denied = self.invoke("read", "--path", "Ghost.md")
284
+ self.assertEqual(denied["exit_code"], 2)
285
+
286
+ def test_limits_and_consent(self):
287
+ self.request["read_scopes"] = [{"kind": "directory", "path": "Padding"}]
288
+ self.request["limits"]["max_files"] = 2
289
+ report = self.invoke("inspect")
290
+ self.assertEqual(report["coverage"]["processed"], ["Padding/note-001.md", "Padding/note-002.md"])
291
+ self.assertEqual(len(report["coverage"]["unprocessed"]), 78)
292
+ self.assertEqual(report["exit_code"], 3)
293
+ self.request["consent"]["model_context"] = False
294
+ with patch.object(vault.Vault, "read", side_effect=AssertionError("Unauthorized read")):
295
+ self.assertEqual(self.invoke("inspect")["exit_code"], 2)
296
+
297
+ def test_unsupported_metadata_and_non_utf8_are_explicit(self):
298
+ (self.root / "Notes/alias.md").hardlink_to(self.root / "Notes/draft.md")
299
+ blocked = self.edit_plan()
300
+ self.assertEqual(blocked["exit_code"], 4)
301
+ self.assertEqual(blocked["issues"][0]["code"], "capability_missing")
302
+ self.write("Notes/invalid.md", b"\xff")
303
+ self.assertEqual(self.invoke("read", "--path", "Notes/invalid.md")["issues"][0]["code"],
304
+ "unsupported_format")
305
+ self.assertEqual(self.invoke("read", "--path", "Notes/missing.md")["issues"][0]["code"],
306
+ "read_failed")
307
+
308
+ def test_completed_note_variables_and_unauthorized_writes(self):
309
+ self.request.update(intent="create", write_target="Notes/new.md", deliverable="note")
310
+ candidate = self.workspace / "template.md"
311
+ candidate.write_text("# {{title}}\n")
312
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["issues"][0]["code"],
313
+ "gate_blocked")
314
+ self.request["deliverable"] = "template"
315
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["exit_code"], 0)
316
+ for target in ("../outside.md", ".obsidian/private.md", "Notes/plain.txt"):
317
+ self.request["write_target"] = target
318
+ before = self.snapshot()
319
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["exit_code"], 2)
320
+ self.assertEqual(self.snapshot(), before)
321
+
322
+ def test_approved_edit_replay_and_external_change(self):
323
+ initial = self.snapshot()
324
+ planned = self.edit_plan()
325
+ self.assertEqual(planned["status"], "completed")
326
+ self.assertEqual(self.snapshot(), initial)
327
+ saved = self.apply_plan(planned)
328
+ self.assertEqual((saved["status"], saved["write_status"]), ("completed", "saved"))
329
+ self.assertTrue(saved["data"]["changed"])
330
+ current = self.snapshot()
331
+ self.assertEqual([name for name in current if current[name] != initial[name]], ["Notes/draft.md"])
332
+ replayed = self.apply_plan(planned)
333
+ self.assertFalse(replayed["data"]["changed"])
334
+ self.assertEqual(replayed["issues"][0]["code"], "already_applied")
335
+ self.write("Notes/draft.md", initial["Notes/draft.md"])
336
+ blocked = self.apply_plan(planned)
337
+ self.assertEqual(blocked["exit_code"], 2)
338
+ self.assertEqual(blocked["issues"][0]["code"], "target_changed")
339
+ self.assertEqual((self.root / "Notes/draft.md").read_bytes(), initial["Notes/draft.md"])
340
+
341
+ def test_noop_and_missing_semantic_review(self):
342
+ result = self.edit_plan(review=False)
343
+ self.assertEqual(result["exit_code"], 2)
344
+ self.assertEqual(result["issues"][0]["code"], "semantic_review_required")
345
+ self.candidate.write_bytes((self.root / "Notes/draft.md").read_bytes())
346
+ result = self.invoke("plan", "--candidate", self.candidate)
347
+ self.assertEqual(result["exit_code"], 0)
348
+ self.assertTrue(result["data"]["no_changes"])
349
+ self.assertIsNone(result["data"]["plan_path"])
350
+ (self.root / "Notes/linked.md").hardlink_to(self.root / "Notes/draft.md")
351
+ with patch.object(vault, "write_capabilities", side_effect=AssertionError("No write needed")):
352
+ self.assertTrue(self.invoke("plan", "--candidate", self.candidate)["data"]["no_changes"])
353
+
354
+ def test_plan_respects_explicit_byte_limit(self):
355
+ self.edit_plan()
356
+ self.request["limits"]["max_file_bytes"] = 1
357
+ result = self.invoke("plan", "--candidate", self.candidate, "--review", self.review_path)
358
+ self.assertEqual(result["exit_code"], 2)
359
+ self.assertEqual(result["issues"][0]["code"], "limit_reached")
360
+
361
+ def test_stale_original_approval_and_stage_conflict(self):
362
+ planned = self.edit_plan()
363
+ original = (self.root / "Notes/draft.md").read_bytes()
364
+ self.write("Notes/draft.md", original + b"external")
365
+ self.assertEqual(self.apply_plan(planned)["issues"][0]["code"], "target_changed")
366
+ self.write("Notes/draft.md", original)
367
+ bad = self.invoke("apply", "--plan", planned["data"]["plan_path"],
368
+ "--approve-plan-sha256", "0" * 64)
369
+ self.assertEqual(bad["issues"][0]["code"], "plan_not_approved")
370
+ plan = json.loads(Path(planned["data"]["plan_path"]).read_bytes())
371
+ self.write(plan["stage_target"], "Keep existing stage")
372
+ blocked = self.apply_plan(planned)
373
+ self.assertEqual(blocked["issues"][0]["code"], "target_exists")
374
+ self.assertEqual((self.root / plan["stage_target"]).read_text(), "Keep existing stage")
375
+
376
+ def test_parent_identity_change(self):
377
+ planned = self.edit_plan()
378
+ (self.root / "Notes").rename(self.root / "OldNotes")
379
+ (self.root / "Notes").mkdir()
380
+ self.write("Notes/draft.md", (self.root / "OldNotes/draft.md").read_bytes())
381
+ result = self.apply_plan(planned)
382
+ self.assertEqual(result["issues"][0]["code"], "parent_changed")
383
+
384
+ def test_protected_mutations_block_even_with_attestation(self):
385
+ fixture = CASES["protected_edit_fixture"]
386
+ before = fixture["content"].encode()
387
+ for mutation in fixture["mutations"]:
388
+ with self.subTest(case=mutation["case"]):
389
+ after = before.replace(*(value.encode() for value in mutation["replace"]))
390
+ review = {
391
+ "before_sha256": vault.digest(before), "after_sha256": vault.digest(after),
392
+ "reviewer": "Artificial fixture; not a semantic guarantee",
393
+ "checks": {key: True for key in vault.CHECKS},
394
+ }
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_create_and_conflict_without_replacement(self):
400
+ self.request.update(intent="create", write_target="Notes/new.md", deliverable="note")
401
+ candidate = self.workspace / "new.md"
402
+ candidate.write_text("# 作成ノート\n\n2026-10-19\n")
403
+ planned = self.invoke("plan", "--candidate", candidate)
404
+ self.assertEqual(planned["exit_code"], 0)
405
+ saved = self.apply_plan(planned)
406
+ self.assertEqual(saved["exit_code"], 0)
407
+ self.assertEqual((self.root / "Notes/new.md").read_bytes(), candidate.read_bytes())
408
+ mask = os.umask(0)
409
+ os.umask(mask)
410
+ self.assertEqual((self.root / "Notes/new.md").stat().st_mode & 0o777, 0o666 & ~mask)
411
+ self.assertEqual(self.invoke("plan", "--candidate", candidate)["issues"][0]["code"], "target_exists")
412
+
413
+ def test_precommit_and_postcommit_failure(self):
414
+ planned = self.edit_plan()
415
+ original = (self.root / "Notes/draft.md").read_bytes()
416
+ replace = os.replace
417
+
418
+ def fail_commit(source, target, **kwargs):
419
+ if "src_dir_fd" in kwargs:
420
+ raise OSError("Injected precommit failure")
421
+ return replace(source, target, **kwargs)
422
+
423
+ with patch.object(vault.os, "replace", fail_commit):
424
+ result = self.apply_plan(planned)
425
+ self.assertEqual((result["status"], result["write_status"]), ("failed", "unsaved"))
426
+ self.assertEqual((self.root / "Notes/draft.md").read_bytes(), original)
427
+ self.assertFalse(list((self.root / "Notes").glob(".kotonoha-*")))
428
+ actual = vault.replace_private
429
+
430
+ def fail_receipt(path, data):
431
+ if path.name == "receipt.json":
432
+ raise OSError("Injected postcommit failure")
433
+ return actual(path, data)
434
+
435
+ with patch.object(vault, "replace_private", fail_receipt):
436
+ result = self.apply_plan(planned)
437
+ self.assertEqual((result["status"], result["write_status"]), ("failed", "unknown"))
438
+ self.assertNotEqual((self.root / "Notes/draft.md").read_bytes(), original)
439
+ self.assertEqual(self.apply_plan(planned)["issues"][0]["code"], "commit_outcome_uncertain")
440
+
441
+ def test_cli_shapes_and_control_failures(self):
442
+ self.request_path.write_text(json.dumps(self.request))
443
+ result = subprocess.run(
444
+ [sys.executable, str(Path(vault.__file__)), "read", "--request",
445
+ str(self.request_path), "--path", "Notes/draft.md"],
446
+ capture_output=True, text=True,
447
+ )
448
+ self.assertEqual(result.returncode, 0, result.stderr)
449
+ self.assertEqual(json.loads(result.stdout)["data"]["path"], "Notes/draft.md")
450
+ self.assertEqual(vault.run(["apply"])["exit_code"], 1)
451
+ self.request_path.write_text('{"schema_version":1,"schema_version":1}')
452
+ self.assertEqual(vault.run(["inspect", "--request", str(self.request_path)])["exit_code"], 1)
453
+ self.assertEqual(self.invoke("read", "--path", "../escape.md")["exit_code"], 2)
454
+ self.assertEqual(self.invoke("read", "--path", ".obsidian/private.md")["exit_code"], 2)
455
+
456
+ def test_decorated_heading_never_becomes_plain_after_masking(self):
457
+ for heading in ("`code`", "Text %% comment %%", "==marked==", r"escaped\_text", "&amp;"):
458
+ with self.subTest(heading=heading):
459
+ self.write("Targets/Unique.md", "# Unique\n\n## " + heading + "\n")
460
+ report = self.invoke("inspect")
461
+ missing = next(link for link in report["data"]["links"]
462
+ if link["raw"] == "[[Targets/Unique#Missing]]")
463
+ self.assertEqual(missing["reason"], "unsupported_heading")
464
+ self.assertNotEqual(missing["classification"], "unresolved")
465
+
466
+ def test_nested_markdown_labels_are_protected(self):
467
+ before = b"# Test\n\n[a [b] c](https://example.com/path)\n"
468
+ after = before.replace(b"[b]", b"[d]")
469
+ review = {"before_sha256": vault.digest(before), "after_sha256": vault.digest(after),
470
+ "reviewer": "Artificial fixture", "checks": {key: True for key in vault.CHECKS}}
471
+ with self.assertRaises(vault.Failure) as blocked:
472
+ vault.edit_gate(before, after, [], review)
473
+ self.assertEqual(blocked.exception.code, "gate_blocked")
474
+
475
+ def test_escaped_link_examples_and_no_final_newline_diff(self):
476
+ self.write("Notes/source.md", "# Source\n\n\\[[Example]]\n\\[x](missing.md)\n[[Targets/Unique]]\n")
477
+ report = self.invoke("inspect")
478
+ links = [link for link in report["data"]["links"] if link["source_path"] == "Notes/source.md"]
479
+ self.assertEqual([link["raw"] for link in links], ["[[Targets/Unique]]"])
480
+ diff = vault.diff_bytes(b"before", b"after", "note.md").decode()
481
+ self.assertIn("-before\n\\n", diff)
482
+ self.assertIn("+after\n\\n", diff)
483
+
484
+ def test_create_race_does_not_replace_external_file(self):
485
+ self.request.update(intent="create", write_target="Notes/new.md", deliverable="note")
486
+ candidate = self.workspace / "new.md"
487
+ candidate.write_text("# New\n")
488
+ planned = self.invoke("plan", "--candidate", candidate)
489
+ create = vault.exclusive_create
490
+
491
+ def race(parent, stage_name, name):
492
+ self.write("Notes/new.md", b"External winner\n")
493
+ return create(parent, stage_name, name)
494
+
495
+ with patch.object(vault, "exclusive_create", race):
496
+ blocked = self.apply_plan(planned)
497
+ self.assertEqual((blocked["status"], blocked["exit_code"]), ("not_performed", 2))
498
+ self.assertEqual((self.root / "Notes/new.md").read_bytes(), b"External winner\n")
499
+ self.assertFalse(list((self.root / "Notes").glob(".kotonoha-*")))
500
+
501
+ def test_stale_base_and_hash_bound_review(self):
502
+ self.edit_plan()
503
+ original = (self.root / "Notes/draft.md").read_bytes()
504
+ self.write("Notes/draft.md", original + b"External change\n")
505
+ result = self.invoke("plan", "--candidate", self.candidate, "--review", self.review_path)
506
+ self.assertEqual(result["issues"][0]["code"], "target_changed")
507
+ self.write("Notes/draft.md", original)
508
+ review = json.loads(self.review_path.read_bytes())
509
+ for alteration in ({"after_sha256": "0" * 64},
510
+ {"checks": {**review["checks"], "actors": False}},
511
+ {"checks": {**review["checks"], "actors": "true"}}):
512
+ with self.subTest(alteration=alteration):
513
+ self.review_path.write_text(json.dumps(review | alteration))
514
+ result = self.invoke("plan", "--candidate", self.candidate, "--review", self.review_path)
515
+ self.assertEqual(result["issues"][0]["code"], "semantic_review_required")
516
+
517
+ def test_receipt_missing_or_invalid_does_not_trigger_second_write(self):
518
+ planned = self.edit_plan()
519
+ self.assertEqual(self.apply_plan(planned)["exit_code"], 0)
520
+ operation = Path(planned["data"]["plan_path"]).parent
521
+ receipt = operation / "receipt.json"
522
+ receipt.unlink()
523
+ result = self.apply_plan(planned)
524
+ self.assertEqual(result["issues"][0]["code"], "commit_outcome_uncertain")
525
+ receipt.write_text('{"verified": false}')
526
+ self.assertEqual(self.apply_plan(planned)["issues"][0]["code"], "commit_outcome_uncertain")
527
+
528
+ def test_concurrent_actual_cli_serializes_same_operation(self):
529
+ planned = self.edit_plan()
530
+ arguments = [sys.executable, vault.__file__, "apply", "--request", str(self.request_path),
531
+ "--plan", planned["data"]["plan_path"],
532
+ "--approve-plan-sha256", planned["data"]["plan_sha256"]]
533
+ children = [subprocess.Popen(arguments, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
534
+ text=True) for _ in range(2)]
535
+ results = []
536
+ for child in children:
537
+ stdout, stderr = child.communicate(timeout=30)
538
+ self.assertEqual(child.returncode, 0, stderr)
539
+ results.append(json.loads(stdout))
540
+ self.assertEqual(sorted(result["data"]["changed"] for result in results), [False, True])
541
+ self.assertTrue(all(result["data"]["verified"] for result in results))
542
+
543
+ def test_installed_offline_launcher_and_single_skill_dependency_failure(self):
544
+ installed = self.home / "installed skills"
545
+ subprocess.run(["node", str(ROOT / "bin/kotonoha.js"), "install", "--target", str(installed)],
546
+ check=True, capture_output=True)
547
+ executable = installed / "obsidian-vault/scripts/vault.py"
548
+ self.request_path.write_text(json.dumps(self.request))
549
+ command = ["uv", "run", "--offline", "--locked", "--python", "3.12", str(executable)]
550
+ result = subprocess.run(command + ["inspect", "--request", str(self.request_path)],
551
+ capture_output=True, text=True)
552
+ self.assertEqual(result.returncode, 3, result.stderr)
553
+ self.assertEqual(json.loads(result.stdout)["data"]["links"], CASES["expected_links"])
554
+ single = self.home / "single"
555
+ subprocess.run(["node", str(ROOT / "bin/kotonoha.js"), "install", "--skill", "obsidian-vault",
556
+ "--target", str(single)], check=True, capture_output=True)
557
+ self.edit_plan()
558
+ result = subprocess.run(
559
+ ["uv", "run", "--offline", "--locked", "--python", "3.12",
560
+ str(single / "obsidian-vault/scripts/vault.py"), "plan", "--request",
561
+ str(self.request_path), "--candidate", str(self.candidate),
562
+ "--review", str(self.review_path)], capture_output=True, text=True,
563
+ )
564
+ self.assertEqual(result.returncode, 4, result.stderr)
565
+ self.assertEqual(json.loads(result.stdout)["issues"][0]["code"], "dependency_missing")
566
+
567
+
568
+ if __name__ == "__main__":
569
+ unittest.main()