kotonoha 0.3.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.
- package/CHANGELOG.md +37 -0
- package/README-ja.md +32 -5
- package/README.md +56 -6
- package/package.json +5 -3
- package/skills/japanese-prose/NOTICE.md +7 -1
- package/skills/japanese-prose/SKILL.md +44 -15
- package/skills/japanese-prose/references/review-workflow.md +57 -0
- package/skills/japanese-prose/references/scoring.md +6 -0
- package/skills/japanese-prose/scripts/core.py +6 -2
- package/skills/japanese-prose/scripts/invariants.py +200 -0
- package/skills/japanese-prose/scripts/lint.py +68 -3
- package/skills/japanese-prose/scripts/test_engine.py +168 -0
- package/skills/obsidian-vault/SKILL.md +106 -0
- package/skills/obsidian-vault/references/execution-contract.json +479 -0
- package/skills/obsidian-vault/references/workflow.md +151 -0
- package/skills/obsidian-vault/scripts/test_vault.py +493 -0
- package/skills/obsidian-vault/scripts/test_vault.py.lock +143 -0
- package/skills/obsidian-vault/scripts/vault.py +1316 -0
- package/skills/obsidian-vault/scripts/vault.py.lock +143 -0
- package/skills/tech-writer/SKILL.md +16 -7
- package/skills/tech-writer/assets/templates/obsidian.md +37 -0
- package/skills/tech-writer/references/doctypes/obsidian.md +106 -0
- package/skills/tech-writer/references/japanese-prose-optimization.md +14 -6
|
@@ -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", "&"):
|
|
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()
|