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,1316 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = "==3.12.*"
4
+ # dependencies = ["markdown-it-py>=3,<5", "PyYAML>=6,<7", "jsonschema>=4,<5"]
5
+ # ///
6
+ """Diagnose authorized Markdown notes and apply explicitly approved single-note plans."""
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from collections import defaultdict
12
+ from contextlib import contextmanager
13
+ from datetime import date
14
+ import difflib
15
+ import fcntl
16
+ import hashlib
17
+ import importlib.util
18
+ import json
19
+ import math
20
+ import os
21
+ from pathlib import Path, PurePosixPath
22
+ import posixpath
23
+ import re
24
+ import stat
25
+ import sys
26
+ from urllib.parse import unquote_to_bytes
27
+
28
+ from jsonschema import Draft202012Validator, ValidationError
29
+ from markdown_it import MarkdownIt
30
+ from markdown_it.helpers import parseLinkDestination, parseLinkLabel, parseLinkTitle
31
+ from markdown_it.rules_inline.state_inline import StateInline
32
+ import yaml
33
+
34
+
35
+ SKILL = Path(__file__).resolve().parent.parent
36
+ CONTRACT_PATH = SKILL / "references" / "execution-contract.json"
37
+ MANAGEMENT = {".git", ".obsidian"}
38
+ MARKDOWN = MarkdownIt("commonmark").enable("table")
39
+ CHECKS = (
40
+ "actors", "negation", "conditions", "modality", "scope", "causality",
41
+ "quantity_bindings", "terminology", "structure",
42
+ )
43
+
44
+
45
+ class Failure(Exception):
46
+ def __init__(self, code, path=None, *, status="not_performed", exit_code=2):
47
+ super().__init__(code)
48
+ self.code, self.path = code, path
49
+ self.status, self.exit_code = status, exit_code
50
+
51
+
52
+ def digest(data):
53
+ return hashlib.sha256(data).hexdigest()
54
+
55
+
56
+ def canonical(data):
57
+ return json.dumps(data, ensure_ascii=False, sort_keys=True,
58
+ separators=(",", ":"), allow_nan=False).encode("utf-8")
59
+
60
+
61
+ def strict_json(data):
62
+ def pairs(items):
63
+ result = {}
64
+ for key, value in items:
65
+ if key in result:
66
+ raise ValueError("Duplicate JSON key")
67
+ result[key] = value
68
+ return result
69
+
70
+ def invalid_constant(value):
71
+ raise ValueError("Non-finite JSON number")
72
+
73
+ return json.loads(data, object_pairs_hook=pairs, parse_constant=invalid_constant)
74
+
75
+
76
+ CONTRACT = strict_json(CONTRACT_PATH.read_bytes())
77
+
78
+
79
+ def validate(value, definition):
80
+ Draft202012Validator({
81
+ "$ref": f"#/$defs/{definition}", "$defs": CONTRACT["$defs"],
82
+ }).validate(value)
83
+
84
+
85
+ def issue(code, path=None):
86
+ return {"code": code, "path": path, "message": code}
87
+
88
+
89
+ def relative(path, *, dot=False):
90
+ if not isinstance(path, str) or not path or "\0" in path or path.startswith("/"):
91
+ raise Failure("invalid_path")
92
+ parts = path.split("/")
93
+ if ".." in parts:
94
+ raise Failure("invalid_path", path)
95
+ normalized = posixpath.normpath(path)
96
+ if normalized == "." and not dot:
97
+ raise Failure("invalid_path", path)
98
+ if MANAGEMENT.intersection(parts):
99
+ raise Failure("excluded_management_directory", path)
100
+ return normalized
101
+
102
+
103
+ def directory(path):
104
+ path = os.fspath(path)
105
+ if not os.path.isabs(path) or "\0" in path or ".." in path.split("/"):
106
+ raise Failure("invalid_path")
107
+ descriptor = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
108
+ try:
109
+ for part in Path(path).parts[1:]:
110
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
111
+ dir_fd=descriptor)
112
+ os.close(descriptor)
113
+ descriptor = child
114
+ return descriptor
115
+ except (OSError, Failure):
116
+ os.close(descriptor)
117
+ raise
118
+
119
+
120
+ class Vault:
121
+ def __init__(self, request):
122
+ self.root = os.path.normpath(request["vault_root"])
123
+ self.scopes = [
124
+ (scope["kind"], relative(scope["path"], dot=scope["kind"] == "directory"))
125
+ for scope in request["read_scopes"]
126
+ ]
127
+ try:
128
+ self.fd = directory(self.root)
129
+ except OSError as error:
130
+ raise Failure("invalid_request", status="failed", exit_code=1) from error
131
+
132
+ def close(self):
133
+ os.close(self.fd)
134
+
135
+ def allowed(self, path):
136
+ return any(
137
+ path == scope or kind == "directory" and (
138
+ scope == "." or path.startswith(scope + "/")
139
+ ) for kind, scope in self.scopes
140
+ )
141
+
142
+ @contextmanager
143
+ def parent(self, path):
144
+ path = relative(path)
145
+ descriptor = os.dup(self.fd)
146
+ try:
147
+ for part in path.split("/")[:-1]:
148
+ try:
149
+ child = os.open(
150
+ part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
151
+ dir_fd=descriptor,
152
+ )
153
+ except OSError as error:
154
+ if error.errno in (20, 40):
155
+ raise Failure("symlink_not_followed", path) from error
156
+ raise
157
+ os.close(descriptor)
158
+ descriptor = child
159
+ yield descriptor, path.split("/")[-1]
160
+ finally:
161
+ os.close(descriptor)
162
+
163
+ def read(self, path, maximum=None):
164
+ path = relative(path)
165
+ if not self.allowed(path):
166
+ raise Failure("outside_scope", path)
167
+ if not path.endswith(".md"):
168
+ raise Failure("unsupported_format", path)
169
+ with self.parent(path) as (parent, name):
170
+ try:
171
+ descriptor = os.open(
172
+ name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent,
173
+ )
174
+ except OSError as error:
175
+ if error.errno == 40:
176
+ raise Failure("symlink_not_followed", path) from error
177
+ raise
178
+ with os.fdopen(descriptor, "rb") as stream:
179
+ metadata = os.fstat(stream.fileno())
180
+ if not stat.S_ISREG(metadata.st_mode):
181
+ raise Failure("unsupported_format", path)
182
+ if maximum is not None and metadata.st_size > maximum:
183
+ raise Failure("limit_reached", path)
184
+ data = stream.read() if maximum is None else stream.read(maximum + 1)
185
+ if maximum is not None and len(data) > maximum:
186
+ raise Failure("limit_reached", path)
187
+ return data, metadata
188
+
189
+ def enumerate(self):
190
+ paths, skipped, issues = set(), set(), []
191
+ enumeration_complete = True
192
+
193
+ def walk(descriptor, prefix):
194
+ nonlocal enumeration_complete
195
+ try:
196
+ names = sorted(os.listdir(descriptor))
197
+ except OSError:
198
+ skipped.add(prefix)
199
+ issues.append(issue("read_failed", prefix))
200
+ enumeration_complete = False
201
+ return
202
+ for name in names:
203
+ path = name if prefix == "." else prefix + "/" + name
204
+ if name in MANAGEMENT:
205
+ issues.append(issue("excluded_management_directory", path))
206
+ continue
207
+ try:
208
+ metadata = os.stat(name, dir_fd=descriptor, follow_symlinks=False)
209
+ if stat.S_ISLNK(metadata.st_mode):
210
+ skipped.add(path)
211
+ issues.append(issue("symlink_not_followed", path))
212
+ enumeration_complete = False
213
+ elif stat.S_ISDIR(metadata.st_mode):
214
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
215
+ dir_fd=descriptor)
216
+ try:
217
+ walk(child, path)
218
+ finally:
219
+ os.close(child)
220
+ elif name.endswith(".md"):
221
+ if stat.S_ISREG(metadata.st_mode):
222
+ paths.add(path)
223
+ else:
224
+ skipped.add(path)
225
+ issues.append(issue("unsupported_format", path))
226
+ except OSError:
227
+ skipped.add(path)
228
+ issues.append(issue("read_failed", path))
229
+ enumeration_complete = False
230
+
231
+ for kind, path in self.scopes:
232
+ if kind == "file":
233
+ if not path.endswith(".md"):
234
+ raise Failure("unsupported_format", path)
235
+ paths.add(path)
236
+ continue
237
+ try:
238
+ if path == ".":
239
+ walk(self.fd, ".")
240
+ else:
241
+ with self.parent(path) as (parent, name):
242
+ child = os.open(name, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
243
+ dir_fd=parent)
244
+ try:
245
+ walk(child, path)
246
+ finally:
247
+ os.close(child)
248
+ except (OSError, Failure):
249
+ skipped.add(path)
250
+ issues.append(issue("read_failed", path))
251
+ enumeration_complete = False
252
+ return sorted(paths), skipped, issues, enumeration_complete
253
+
254
+
255
+ def yaml_properties(frontmatter):
256
+ for event in yaml.parse(frontmatter):
257
+ if getattr(event, "anchor", None) or isinstance(event, yaml.AliasEvent):
258
+ raise ValueError("YAML anchors unsupported")
259
+ tag = getattr(event, "tag", None)
260
+ if tag and not tag.startswith("tag:yaml.org,2002:"):
261
+ raise ValueError("Custom YAML tag unsupported")
262
+ node = yaml.compose(frontmatter, Loader=yaml.SafeLoader)
263
+ if node is None:
264
+ return {}
265
+ if not isinstance(node, yaml.MappingNode):
266
+ raise ValueError("Properties must be a mapping")
267
+ keys = [key.value for key, _ in node.value if isinstance(key, yaml.ScalarNode)]
268
+ if len(keys) != len(node.value) or len(set(keys)) != len(keys):
269
+ raise ValueError("Duplicate or complex YAML key")
270
+ values = yaml.safe_load(frontmatter)
271
+
272
+ def scalar(value):
273
+ return (value is None or isinstance(value, (str, bool, int, date))
274
+ or isinstance(value, float) and math.isfinite(value))
275
+
276
+ for key, value in values.items():
277
+ if not isinstance(key, str) or not key:
278
+ raise ValueError("Non-string property key")
279
+ if not scalar(value) and not (
280
+ isinstance(value, list) and all(scalar(item) for item in value)
281
+ ):
282
+ raise ValueError("Nested property unsupported")
283
+ if key in {"aliases", "tags", "cssclasses"} and not (
284
+ isinstance(value, list) and all(isinstance(item, str) for item in value)
285
+ ):
286
+ raise ValueError("Expected string list")
287
+ return values
288
+
289
+
290
+ def masked_body(body):
291
+ chars = list(body)
292
+ fatal = False
293
+ comments = []
294
+
295
+ def hide(start, end, *, inline=False):
296
+ for index in range(start, end):
297
+ if chars[index] not in "\r\n":
298
+ chars[index] = "x" if inline else " "
299
+
300
+ def blocks(text):
301
+ lines = text.splitlines(keepends=True)
302
+ offsets = [0]
303
+ for line in lines:
304
+ offsets.append(offsets[-1] + len(line))
305
+ found, containers, tables = {}, [], []
306
+ for token in MARKDOWN.parse(text):
307
+ if token.type == "inline" and token.map:
308
+ containers.append((offsets[token.map[0]], offsets[token.map[1]]))
309
+ if token.type == "table_open" and token.map:
310
+ tables.append((offsets[token.map[0]], offsets[token.map[1]]))
311
+ if token.type in {"fence", "code_block"} and token.map:
312
+ start, end = token.map
313
+ found[offsets[start]] = (offsets[end], token, lines[start:end])
314
+ return found, containers, tables
315
+
316
+ code, containers, tables = blocks(body)
317
+ index = 0
318
+ while index < len(body):
319
+ if index in code:
320
+ end, token, lines = code[index]
321
+ if token.type == "fence" and not (
322
+ len(lines) > 1 and re.match(
323
+ r"^[ >]*" + re.escape(token.markup[0]) + "{"
324
+ + str(len(token.markup)) + r",}\s*$", lines[-1],
325
+ )
326
+ ):
327
+ fatal = True
328
+ hide(index, end)
329
+ index = end
330
+ elif body[index] == "\\":
331
+ index += 2
332
+ elif body.startswith("%%", index):
333
+ close = body.find("%%", index + 2)
334
+ end = len(body) if close < 0 else close + 2
335
+ fatal |= close < 0
336
+ comments.append(body[index:end])
337
+ hide(index, end, inline=True)
338
+ index = end
339
+ elif body[index] == "`":
340
+ opening = re.match(r"`+", body[index:]).group()
341
+ containing = [end for start, end in containers if start <= index < end]
342
+ newline = body.find("\n", index)
343
+ bound = min(containing) if containing else len(body) if newline < 0 else newline
344
+ bound = min([bound] + [start for start in code if start > index])
345
+ if any(start <= index < end for start, end in tables):
346
+ bound = min(bound, len(body) if newline < 0 else newline)
347
+ for position in range(index + len(opening), bound):
348
+ if body[position] == "|":
349
+ previous = position - 1
350
+ while previous >= 0 and body[previous] == "\\":
351
+ previous -= 1
352
+ if (position - previous - 1) % 2 == 0:
353
+ bound = position
354
+ break
355
+ close = re.search(r"(?<!`)" + re.escape(opening) + r"(?!`)",
356
+ body[index + len(opening):bound])
357
+ if close:
358
+ end = index + len(opening) + close.end()
359
+ hide(index, end, inline=True)
360
+ index = end
361
+ else:
362
+ index += len(opening)
363
+ else:
364
+ index += 1
365
+ for start, (end, token, lines) in blocks("".join(chars))[0].items():
366
+ if token.type == "fence" and not (
367
+ len(lines) > 1 and re.match(
368
+ r"^[ >]*" + re.escape(token.markup[0]) + "{"
369
+ + str(len(token.markup)) + r",}\s*$", lines[-1],
370
+ )
371
+ ):
372
+ fatal = True
373
+ hide(start, end)
374
+ return "".join(chars), fatal, comments
375
+
376
+
377
+ def extract_links(body, masked, line_offset):
378
+ tokens = MARKDOWN.parse(masked)
379
+ blocks = []
380
+ for token in tokens:
381
+ if token.map:
382
+ blocks.append(tuple(token.map))
383
+ links, external, all_raw = [], 0, []
384
+ index = 0
385
+ state = StateInline(body, MARKDOWN, {}, [])
386
+ while index < len(masked):
387
+ if masked[index] == "\\":
388
+ index += 2
389
+ continue
390
+ start = index
391
+ image = masked.startswith("![", start)
392
+ bracket = start + 1 if image else start
393
+ syntax = None
394
+ destination = ""
395
+ if masked.startswith("[[", bracket):
396
+ close = masked.find("]]", bracket + 2)
397
+ newline = masked.find("\n", bracket)
398
+ if close >= 0 and (newline < 0 or close < newline):
399
+ end = close + 2
400
+ syntax = "wiki"
401
+ destination = body[bracket + 2:close].replace("\\|", "|").split("|", 1)[0]
402
+ if "[[" in destination:
403
+ syntax = "unsupported"
404
+ else:
405
+ end = len(masked) if newline < 0 else newline
406
+ syntax = "unsupported"
407
+ elif masked.startswith("[", bracket):
408
+ close = parseLinkLabel(state, bracket, False)
409
+ if close >= 0 and masked.startswith("(", close + 1):
410
+ position = close + 2
411
+ while position < len(body) and body[position].isspace():
412
+ position += 1
413
+ parsed = parseLinkDestination(body, position, len(body))
414
+ if parsed.ok:
415
+ destination = parsed.str
416
+ position = parsed.pos
417
+ while position < len(body) and body[position].isspace():
418
+ position += 1
419
+ if position < len(body) and body[position] in "\"'(":
420
+ title = parseLinkTitle(body, position, len(body))
421
+ if title.ok:
422
+ position = title.pos
423
+ while position < len(body) and body[position].isspace():
424
+ position += 1
425
+ if position < len(body) and body[position] == ")":
426
+ end = position + 1
427
+ syntax = "unsupported" if image else "markdown"
428
+ if syntax is None:
429
+ newline = masked.find("\n", bracket)
430
+ end = len(masked) if newline < 0 else newline
431
+ syntax = "unsupported"
432
+ if syntax is None:
433
+ url = re.match(r"https?://[^\s<>]+", masked[index:])
434
+ if url:
435
+ external += 1
436
+ index += len(url.group())
437
+ else:
438
+ index += 1
439
+ continue
440
+ all_raw.append(body[start:end])
441
+ if re.match(r"^(?:https?|mailto|ftp):", destination, re.I):
442
+ external += 1
443
+ index = end
444
+ continue
445
+ line = body.count("\n", 0, start)
446
+ containing = [(a, b) for a, b in blocks if a <= line < b]
447
+ block_line = min((a for a, _ in containing), default=line)
448
+ links.append({
449
+ "raw": body[start:end], "syntax": syntax, "destination": destination,
450
+ "block_start_line": line_offset + block_line + 1,
451
+ "occurrence": len(links) + 1,
452
+ })
453
+ index = end
454
+ return links, external, all_raw
455
+
456
+
457
+ def parse_note(path, data):
458
+ text = data.decode("utf-8")
459
+ lines = text.splitlines(keepends=True)
460
+ properties, offset, fatal = {}, 0, False
461
+ if lines and lines[0].strip() == "---":
462
+ close = next((i for i in range(1, len(lines))
463
+ if lines[i].strip() in {"---", "..."}), None)
464
+ if close is None:
465
+ fatal = True
466
+ else:
467
+ offset = close + 1
468
+ try:
469
+ properties = yaml_properties("".join(lines[1:close]))
470
+ except (ValueError, yaml.YAMLError):
471
+ fatal = True
472
+ body = "".join(lines[offset:])
473
+ masked, incomplete, comments = masked_body(body)
474
+ fatal |= incomplete
475
+ headings, all_headings, unsupported = [], [], False
476
+ tokens = MARKDOWN.parse(masked)
477
+ for index, token in enumerate(tokens):
478
+ if token.type == "heading_open":
479
+ inline = tokens[index + 1]
480
+ start, end = token.map
481
+ source_lines = body.splitlines()
482
+ raw = source_lines[start]
483
+ marker = re.match(r"^[ \t>]*(?:(?:[-+*]|\d+[.)])[ \t]+)?#{1,6}(?:[ \t]+|$)", raw)
484
+ if marker:
485
+ raw = re.sub(r"[ \t]+#+[ \t]*$", "", raw[marker.end():]).strip(" \t")
486
+ else:
487
+ raw = "\n".join(line.lstrip(" >") for line in source_lines[start:end - 1])
488
+ all_headings.append(raw)
489
+ children = MARKDOWN.parseInline(raw)[0].children or []
490
+ plain = (len(children) == 1 and children[0].type == "text"
491
+ and children[0].content == raw
492
+ and not any(marker in raw for marker in ("[[", "]]", "==", "~~", "%%")))
493
+ if plain:
494
+ headings.append(raw)
495
+ else:
496
+ unsupported = True
497
+ links, external, all_raw = extract_links(body, masked, offset) if not fatal else ([], 0, [])
498
+ return {
499
+ "path": path, "data": data, "sha256": digest(data), "properties": properties,
500
+ "headings": headings, "all_headings": all_headings, "unsupported_heading": unsupported,
501
+ "links": links, "external": external, "fatal": fatal, "comments": comments,
502
+ "all_link_raw": all_raw,
503
+ }
504
+
505
+
506
+ def resolve_link(source, link, notes, vault, coverage):
507
+ raw, syntax = link["destination"], link["syntax"]
508
+ candidates = []
509
+
510
+ def result(classification, reason):
511
+ return {key: link[key] for key in ("raw", "block_start_line", "occurrence")} | {
512
+ "source_path": source, "classification": classification,
513
+ "reason": reason, "candidates": candidates,
514
+ }
515
+
516
+ if syntax == "unsupported" or "^" in raw or not raw:
517
+ return result("indeterminate", "unsupported_syntax")
518
+ path, separator, heading = raw.partition("#")
519
+ if syntax == "markdown":
520
+ extension = PurePosixPath(path).suffix
521
+ if path.startswith(("/", "\\")) or (
522
+ extension and "%" not in extension and extension != ".md"
523
+ ):
524
+ return result("indeterminate", "unsupported_syntax")
525
+ try:
526
+ if re.search(r"%(?![a-fA-F0-9]{2})", path + heading):
527
+ raise ValueError("Invalid encoding")
528
+ path = unquote_to_bytes(path).decode("utf-8")
529
+ heading = unquote_to_bytes(heading).decode("utf-8")
530
+ except (ValueError, UnicodeError):
531
+ return result("indeterminate", "invalid_encoding")
532
+ if not path.endswith(".md") or path.startswith("/") or re.match(r"^[a-zA-Z]+:", path):
533
+ return result("indeterminate", "unsupported_syntax")
534
+ target = posixpath.normpath(posixpath.join(posixpath.dirname(source), path))
535
+ bare = False
536
+ else:
537
+ if path.startswith("/") or re.search(r"\.(?:png|jpe?g|gif|pdf|webp|svg|mp[34]|canvas|base)$", path, re.I):
538
+ return result("indeterminate", "unsupported_syntax")
539
+ bare = bool(path) and "/" not in path
540
+ target = source if not path and separator else path if path.endswith(".md") else path + ".md"
541
+ if target == ".." or target.startswith("../") or "\0" in target:
542
+ return result("indeterminate", "outside_scope")
543
+ try:
544
+ target = relative(target)
545
+ except Failure:
546
+ return result("indeterminate", "outside_scope")
547
+ if bare:
548
+ candidates = sorted(name for name in notes if PurePosixPath(name).name == target)
549
+ if len(candidates) > 1:
550
+ return result("ambiguous", "multiple_notes")
551
+ if not (coverage["whole_vault"] and coverage["enumeration_complete"] and coverage["reads_complete"]):
552
+ return result("indeterminate", "scope_incomplete")
553
+ else:
554
+ if not vault.allowed(target):
555
+ return result("indeterminate", "outside_scope")
556
+ if any(target == name or target.startswith(name + "/") for name in coverage["unprocessed"]):
557
+ return result("indeterminate", "scope_incomplete")
558
+ if target in notes:
559
+ candidates = [target]
560
+ if not candidates:
561
+ return result("unresolved", "note_missing")
562
+ note = notes[candidates[0]]
563
+ if note["fatal"]:
564
+ return result("indeterminate", "target_parse_incomplete")
565
+ if not separator:
566
+ return result("resolved", "note_found")
567
+ count = note["headings"].count(heading)
568
+ if count > 1:
569
+ return result("ambiguous", "multiple_headings")
570
+ if count == 1:
571
+ return result("resolved", "heading_found")
572
+ if note["unsupported_heading"]:
573
+ return result("indeterminate", "unsupported_heading")
574
+ return result("unresolved", "heading_missing")
575
+
576
+
577
+ def property_type(value):
578
+ if value is None:
579
+ return "null"
580
+ if isinstance(value, bool):
581
+ return "boolean"
582
+ if isinstance(value, (int, float)):
583
+ return "number"
584
+ if isinstance(value, date):
585
+ return "date"
586
+ return "list" if isinstance(value, list) else "string"
587
+
588
+
589
+ def observations(notes):
590
+ groups = defaultdict(set)
591
+ for path, note in notes.items():
592
+ groups["name", PurePosixPath(path).stem].add(path)
593
+ groups["folder", str(PurePosixPath(path).parent)].add(path)
594
+ if note["fatal"]:
595
+ continue
596
+ for name, value in note["properties"].items():
597
+ groups["property_name", name].add(path)
598
+ groups["property_type", name + ":" + property_type(value)].add(path)
599
+ for tag in note["properties"].get("tags", []):
600
+ if tag:
601
+ groups["tag", tag].add(path)
602
+ return [
603
+ {"kind": kind, "value": value, "paths": sorted(paths),
604
+ "count": len(paths), "label": "observation"}
605
+ for (kind, value), paths in sorted(groups.items())
606
+ ]
607
+
608
+
609
+ def note_record(note):
610
+ return {"path": note["path"], "sha256": note["sha256"],
611
+ "bytes": len(note["data"]), "heading_texts": note["all_headings"]}
612
+
613
+
614
+ def inspect(request, vault, report):
615
+ paths, skipped, issues, complete = vault.enumerate()
616
+ coverage = report["coverage"]
617
+ coverage["enumeration_complete"] = complete
618
+ coverage["reads_complete"] = not skipped
619
+ report["issues"].extend(issues)
620
+ notes, total = {}, 0
621
+ limits = request["limits"]
622
+ for index, path in enumerate(paths):
623
+ if limits["max_files"] is not None and index >= limits["max_files"]:
624
+ skipped.add(path)
625
+ report["issues"].append(issue("limit_reached", path))
626
+ continue
627
+ maximum = limits["max_file_bytes"]
628
+ if limits["max_total_bytes"] is not None:
629
+ remaining = limits["max_total_bytes"] - total
630
+ maximum = remaining if maximum is None else min(maximum, remaining)
631
+ try:
632
+ data, _ = vault.read(path, maximum)
633
+ total += len(data)
634
+ note = parse_note(path, data)
635
+ notes[path] = note
636
+ coverage["processed"].append(path)
637
+ if note["fatal"]:
638
+ coverage["parses_complete"] = False
639
+ report["issues"].extend([
640
+ issue("source_parse_incomplete", path), issue("unsupported_format", path),
641
+ ])
642
+ except (OSError, UnicodeError, Failure) as error:
643
+ skipped.add(path)
644
+ code = error.code if isinstance(error, Failure) else "unsupported_format" if isinstance(error, UnicodeError) else "read_failed"
645
+ report["issues"].append(issue(code, path))
646
+ coverage["unprocessed"] = sorted(skipped)
647
+ coverage["reads_complete"] &= not skipped
648
+ links = [
649
+ resolve_link(path, link, notes, vault, coverage)
650
+ for path, note in sorted(notes.items()) for link in note["links"]
651
+ ]
652
+ for link in links:
653
+ if link["reason"] == "target_parse_incomplete":
654
+ report["issues"].append(issue("target_parse_incomplete", link["source_path"]))
655
+ declarations = []
656
+ for path in request["declared_rules"]:
657
+ path = relative(path)
658
+ if not vault.allowed(path):
659
+ raise Failure("outside_scope", path)
660
+ if path not in notes:
661
+ raise Failure("read_failed", path, status="failed", exit_code=1)
662
+ declarations.append(note_record(notes[path]))
663
+ report["data"] = {
664
+ "notes": [note_record(note) for _, note in sorted(notes.items())],
665
+ "observations": observations(notes), "declarations": declarations,
666
+ "links": links, "external_url_count": sum(note["external"] for note in notes.values()),
667
+ }
668
+ if skipped or not complete or not coverage["parses_complete"] or any(
669
+ link["classification"] == "indeterminate" for link in links
670
+ ):
671
+ report.update(status="partial", exit_code=3)
672
+
673
+
674
+ def load_gate():
675
+ path = SKILL.parent / "japanese-prose" / "scripts" / "invariants.py"
676
+ if not path.is_file():
677
+ raise Failure("dependency_missing", status="not_performed", exit_code=4)
678
+ spec = importlib.util.spec_from_file_location("kotonoha_invariants", path)
679
+ module = importlib.util.module_from_spec(spec)
680
+ spec.loader.exec_module(module)
681
+ if not callable(getattr(module, "check_bytes", None)) or tuple(module.SEMANTIC_CHECKS) != CHECKS:
682
+ raise Failure("dependency_missing", status="not_performed", exit_code=4)
683
+ return module
684
+
685
+
686
+ def implementation_hash():
687
+ paths = [
688
+ Path(__file__), Path(__file__).with_suffix(".py.lock"),
689
+ SKILL.parent / "japanese-prose" / "scripts" / "invariants.py",
690
+ ]
691
+ try:
692
+ return digest(canonical({path.name: digest(path.read_bytes()) for path in paths}))
693
+ except FileNotFoundError as error:
694
+ raise Failure("dependency_missing", status="not_performed", exit_code=4) from error
695
+
696
+
697
+ def protected_snapshot(note):
698
+ text = note["data"].decode("utf-8")
699
+ return {
700
+ "headings": note["all_headings"],
701
+ "wikilinks": re.findall(r"!?\[\[[^\n]*?\]\]", text),
702
+ "links": note["all_link_raw"],
703
+ "markdown_links": re.findall(r"!?\[[^\]\n]*\]\([^\n]*?\)", text),
704
+ "callouts": re.findall(r"^[ >]*\[![^\]\n]+\][+-]?", text, re.M),
705
+ "comments": note["comments"],
706
+ "definitions": re.findall(r"^[ ]{0,3}\[[^\]\n]+\]:[^\n]*(?:\n[ \t]+[^\n]+)*", text, re.M),
707
+ }
708
+
709
+
710
+ def edit_gate(before, after, terms, review):
711
+ initial, final = parse_note("before.md", before), parse_note("after.md", after)
712
+ if initial["fatal"] or final["fatal"]:
713
+ raise Failure("unsupported_format")
714
+ result = load_gate().check_bytes(before, after, terms, review)
715
+ if protected_snapshot(initial) != protected_snapshot(final):
716
+ raise Failure("gate_blocked")
717
+ if result["status"] == "blocked":
718
+ raise Failure("gate_blocked")
719
+ if result["status"] != "passed":
720
+ raise Failure("semantic_review_required")
721
+
722
+
723
+ def workspace_path(request, path):
724
+ workspace = request["workspace"]
725
+ if not workspace:
726
+ raise Failure("consent_missing")
727
+ workspace = os.path.normpath(workspace)
728
+ root = os.path.normpath(request["vault_root"])
729
+ if not os.path.isabs(workspace) or os.path.commonpath([workspace, root]) == root:
730
+ raise Failure("invalid_path")
731
+ path = os.path.normpath(os.fspath(path))
732
+ if not os.path.isabs(path) or os.path.commonpath([workspace, path]) != workspace:
733
+ raise Failure("outside_scope")
734
+ return workspace, path
735
+
736
+
737
+ def workspace_read(request, path):
738
+ workspace, path = workspace_path(request, path)
739
+ descriptor = directory(workspace)
740
+ try:
741
+ relative_path = os.path.relpath(path, workspace)
742
+ parts = relative(relative_path).split("/")
743
+ for part in parts[:-1]:
744
+ child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
745
+ dir_fd=descriptor)
746
+ os.close(descriptor)
747
+ descriptor = child
748
+ file_descriptor = os.open(parts[-1], os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK,
749
+ dir_fd=descriptor)
750
+ with os.fdopen(file_descriptor, "rb") as stream:
751
+ if not stat.S_ISREG(os.fstat(stream.fileno()).st_mode):
752
+ raise Failure("unsupported_format")
753
+ return stream.read()
754
+ finally:
755
+ os.close(descriptor)
756
+
757
+
758
+ def write_private(path, data, *, reuse=False):
759
+ try:
760
+ descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
761
+ except FileExistsError:
762
+ if reuse:
763
+ descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
764
+ with os.fdopen(descriptor, "rb") as stream:
765
+ metadata = os.fstat(stream.fileno())
766
+ if (stat.S_ISREG(metadata.st_mode) and metadata.st_uid == os.geteuid()
767
+ and stat.S_IMODE(metadata.st_mode) == 0o600
768
+ and stream.read() == data):
769
+ return
770
+ raise Failure("target_exists") from None
771
+ with os.fdopen(descriptor, "wb") as stream:
772
+ stream.write(data)
773
+ stream.flush()
774
+ os.fsync(stream.fileno())
775
+
776
+
777
+ def replace_private(path, data):
778
+ temporary = path.with_name("." + path.name + ".tmp")
779
+ write_private(temporary, data)
780
+ os.replace(temporary, path)
781
+ descriptor = directory(path.parent)
782
+ try:
783
+ os.fsync(descriptor)
784
+ finally:
785
+ os.close(descriptor)
786
+
787
+
788
+ def identity(metadata):
789
+ return {"device": metadata.st_dev, "inode": metadata.st_ino}
790
+
791
+
792
+ def operation_id(plan, parent_metadata):
793
+ return digest(canonical({
794
+ key: plan[key] for key in (
795
+ "request_sha256", "vault_identity", "target", "before_sha256",
796
+ "after_sha256", "review_sha256", "implementation_sha256",
797
+ )
798
+ } | {"target_parent_identity": identity(parent_metadata)}))
799
+
800
+
801
+ def review_data(data):
802
+ review = strict_json(data)
803
+ if (not isinstance(review, dict)
804
+ or set(review) != {"before_sha256", "after_sha256", "reviewer", "checks"}
805
+ or not isinstance(review["checks"], dict) or set(review["checks"]) != set(CHECKS)):
806
+ raise Failure("semantic_review_required")
807
+ return review
808
+
809
+
810
+ def assert_target(target):
811
+ target = relative(target)
812
+ if not target.endswith(".md"):
813
+ raise Failure("invalid_path", target)
814
+ return target
815
+
816
+
817
+ def metadata_supported(metadata, descriptor=None):
818
+ if (metadata.st_uid != os.geteuid() or metadata.st_nlink != 1
819
+ or metadata.st_mode & 0o7000
820
+ or metadata.st_gid not in {os.getegid(), *os.getgroups()}):
821
+ raise Failure("capability_missing", exit_code=4)
822
+ if descriptor is not None:
823
+ try:
824
+ attributes = os.listxattr(descriptor)
825
+ except OSError as error:
826
+ raise Failure("capability_missing", exit_code=4) from error
827
+ if attributes:
828
+ raise Failure("capability_missing", exit_code=4)
829
+
830
+
831
+ def write_capabilities():
832
+ if (not all(hasattr(os, name) for name in ("replace", "link", "fsync", "listxattr"))
833
+ or os.open not in os.supports_dir_fd or os.link not in os.supports_dir_fd):
834
+ raise Failure("capability_missing", exit_code=4)
835
+
836
+
837
+ def maximum_note_bytes(request):
838
+ limits = [value for value in (
839
+ request["limits"]["max_file_bytes"], request["limits"]["max_total_bytes"],
840
+ ) if value is not None]
841
+ return min(limits) if limits else None
842
+
843
+
844
+ def diff_bytes(before, candidate, target):
845
+ lines = difflib.unified_diff(
846
+ (before or b"").decode("utf-8").splitlines(keepends=True),
847
+ candidate.decode("utf-8").splitlines(keepends=True),
848
+ fromfile=target if before is not None else "/dev/null", tofile=target,
849
+ )
850
+ return "".join(
851
+ line if line.endswith(("\n", "\r")) else line + "\n\\n"
852
+ for line in lines
853
+ ).encode("utf-8")
854
+
855
+
856
+ def local_filesystem(descriptor):
857
+ try:
858
+ location = os.readlink(f"/proc/self/fd/{descriptor}")
859
+ matches = []
860
+ for line in Path("/proc/self/mountinfo").read_text().splitlines():
861
+ fields = line.split()
862
+ mount = re.sub(
863
+ r"\\([0-7]{3})", lambda match: chr(int(match[1], 8)), fields[4],
864
+ )
865
+ if mount == "/" or location == mount or location.startswith(mount + "/"):
866
+ matches.append((len(mount), fields[fields.index("-") + 1]))
867
+ filesystem = max(matches)[1] if matches else None
868
+ except (OSError, ValueError, IndexError) as error:
869
+ raise Failure("capability_missing", exit_code=4) from error
870
+ if filesystem not in {"ext2", "ext3", "ext4", "xfs", "btrfs", "tmpfs", "overlay", "zfs", "f2fs"}:
871
+ raise Failure("capability_missing", exit_code=4)
872
+
873
+
874
+ def plan(request, vault, report, args):
875
+ try:
876
+ validate(request, "PlanRequest")
877
+ except ValidationError as error:
878
+ raise Failure("invalid_request") from error
879
+ target = assert_target(request["write_target"])
880
+ if not request["consent"]["workspace_copies"]:
881
+ raise Failure("consent_missing")
882
+ workspace, _ = workspace_path(request, request["workspace"])
883
+ descriptor = directory(workspace)
884
+ os.close(descriptor)
885
+ candidate = workspace_read(request, args.candidate)
886
+ candidate.decode("utf-8")
887
+ review_bytes = None
888
+ before = None
889
+ with vault.parent(target) as (parent, name):
890
+ existing = None
891
+ try:
892
+ existing = os.stat(name, dir_fd=parent, follow_symlinks=False)
893
+ except FileNotFoundError:
894
+ pass
895
+ if request["intent"] == "proofread":
896
+ before, metadata = vault.read(target, maximum_note_bytes(request))
897
+ if digest(before) != request["base_sha256"]:
898
+ raise Failure("target_changed", target)
899
+ if before == candidate:
900
+ report["data"] = {"plan_path": None, "plan_sha256": None,
901
+ "operation_id": None, "gate_status": "passed",
902
+ "no_changes": True}
903
+ return
904
+ metadata_supported(metadata)
905
+ with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
906
+ metadata_supported(os.fstat(stream.fileno()), stream.fileno())
907
+ elif existing is not None:
908
+ raise Failure("target_exists", target)
909
+ write_capabilities()
910
+ local_filesystem(parent)
911
+ maximum = maximum_note_bytes(request)
912
+ if maximum is not None and len(candidate) > maximum:
913
+ raise Failure("limit_reached", target)
914
+ if not (SKILL.parent / "tech-writer" / "SKILL.md").is_file():
915
+ raise Failure("dependency_missing", exit_code=4)
916
+ load_gate()
917
+ review_bytes = workspace_read(request, args.review) if args.review else None
918
+ review = review_data(review_bytes) if review_bytes is not None else None
919
+ if before is not None:
920
+ edit_gate(before, candidate, request["protected_terms"], review)
921
+ else:
922
+ if parse_note(target, candidate)["fatal"]:
923
+ raise Failure("unsupported_format", target)
924
+ if request["deliverable"] == "note" and re.search(r"\{\{[^}]*\}\}", candidate.decode("utf-8")):
925
+ raise Failure("gate_blocked", target)
926
+ planned = {
927
+ "schema_version": 1, "request_sha256": report["request_sha256"],
928
+ "contract_sha256": digest(CONTRACT_PATH.read_bytes()),
929
+ "implementation_sha256": implementation_hash(),
930
+ "vault_identity": identity(os.fstat(vault.fd)), "target": target,
931
+ "kind": "edit" if before is not None else "create",
932
+ "before_sha256": digest(before) if before is not None else None,
933
+ "after_sha256": digest(candidate),
934
+ "review_sha256": digest(review_bytes) if review_bytes is not None else None,
935
+ "state": "validated", "protected_terms": request["protected_terms"],
936
+ }
937
+ planned["operation_id"] = operation_id(planned, os.fstat(parent))
938
+ operation = Path(workspace) / planned["operation_id"]
939
+ try:
940
+ operation.mkdir(mode=0o700)
941
+ except FileExistsError:
942
+ pass
943
+ descriptor = directory(operation)
944
+ try:
945
+ metadata = os.fstat(descriptor)
946
+ if metadata.st_uid != os.geteuid() or stat.S_IMODE(metadata.st_mode) != 0o700:
947
+ raise Failure("capability_missing", exit_code=4)
948
+ finally:
949
+ os.close(descriptor)
950
+ planned["stage_target"] = posixpath.join(
951
+ posixpath.dirname(target), ".kotonoha-" + planned["operation_id"] + ".tmp",
952
+ )
953
+ diff = diff_bytes(before, candidate, target)
954
+ planned["diff_sha256"] = digest(diff)
955
+ contents = {"candidate": candidate, "diff": diff}
956
+ if before is not None:
957
+ contents["before"] = before
958
+ if review_bytes is not None:
959
+ contents["review"] = review_bytes
960
+ filenames = {"candidate": "candidate.md", "before": "before.md",
961
+ "diff": "diff.patch", "review": "semantic-review.json"}
962
+ planned["artifacts"] = {
963
+ key: str(operation / filename) if key in contents else None
964
+ for key, filename in filenames.items()
965
+ }
966
+ for key, data in contents.items():
967
+ write_private(operation / filenames[key], data, reuse=True)
968
+ validate(planned, "Plan")
969
+ plan_bytes = canonical(planned)
970
+ write_private(operation / "plan.json", plan_bytes, reuse=True)
971
+ report.update(operation_state="validated", data={
972
+ "plan_path": str(operation / "plan.json"), "plan_sha256": digest(plan_bytes),
973
+ "operation_id": planned["operation_id"],
974
+ "gate_status": "passed" if before is not None else "not_applicable",
975
+ "no_changes": False,
976
+ })
977
+
978
+
979
+ def exclusive_create(parent, stage_name, name):
980
+ os.link(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent, follow_symlinks=False)
981
+
982
+
983
+ def apply(request, vault, report, args):
984
+ if not request["consent"]["workspace_copies"]:
985
+ raise Failure("consent_missing")
986
+ plan_bytes = workspace_read(request, args.plan)
987
+ if digest(plan_bytes) != args.approve_plan_sha256:
988
+ raise Failure("plan_not_approved")
989
+ planned = strict_json(plan_bytes)
990
+ validate(planned, "Plan")
991
+ if planned["state"] != "validated":
992
+ raise Failure("gate_blocked")
993
+ if planned["request_sha256"] != report["request_sha256"]:
994
+ raise Failure("request_changed")
995
+ if planned["contract_sha256"] != digest(CONTRACT_PATH.read_bytes()):
996
+ raise Failure("implementation_changed")
997
+ if planned["implementation_sha256"] != implementation_hash():
998
+ raise Failure("implementation_changed")
999
+ if planned["vault_identity"] != identity(os.fstat(vault.fd)):
1000
+ raise Failure("parent_changed")
1001
+ if planned["protected_terms"] != request["protected_terms"]:
1002
+ raise Failure("request_changed")
1003
+ target = assert_target(planned["target"])
1004
+ if (target != request["write_target"]
1005
+ or planned["kind"] != ("edit" if request["intent"] == "proofread" else "create")):
1006
+ raise Failure("request_changed", target)
1007
+ if planned["kind"] == "edit" and request["base_sha256"] != planned["before_sha256"]:
1008
+ raise Failure("request_changed", target)
1009
+ workspace, _ = workspace_path(request, args.plan)
1010
+ operation = Path(workspace) / planned["operation_id"]
1011
+ if os.path.normpath(args.plan) != str(operation / "plan.json"):
1012
+ raise Failure("invalid_path")
1013
+ expected_artifacts = {
1014
+ "candidate": operation / "candidate.md", "before": operation / "before.md",
1015
+ "review": operation / "semantic-review.json", "diff": operation / "diff.patch",
1016
+ }
1017
+ loaded = {}
1018
+ for key, path in planned["artifacts"].items():
1019
+ if path is not None:
1020
+ if path != str(expected_artifacts[key]):
1021
+ raise Failure("invalid_path")
1022
+ loaded[key] = workspace_read(request, path)
1023
+ candidate = loaded["candidate"]
1024
+ before = loaded.get("before")
1025
+ review_bytes = loaded.get("review")
1026
+ if digest(candidate) != planned["after_sha256"]:
1027
+ raise Failure("candidate_changed")
1028
+ if digest(loaded["diff"]) != planned["diff_sha256"]:
1029
+ raise Failure("candidate_changed")
1030
+ if (digest(before) if before is not None else None) != planned["before_sha256"]:
1031
+ raise Failure("target_changed")
1032
+ if (digest(review_bytes) if review_bytes is not None else None) != planned["review_sha256"]:
1033
+ raise Failure("semantic_review_required")
1034
+ if before is not None:
1035
+ if not vault.allowed(target):
1036
+ raise Failure("outside_scope", target)
1037
+ edit_gate(before, candidate, request["protected_terms"],
1038
+ review_data(review_bytes) if review_bytes is not None else None)
1039
+ elif (parse_note(target, candidate)["fatal"] or
1040
+ request["deliverable"] == "note" and re.search(r"\{\{[^}]*\}\}", candidate.decode("utf-8"))):
1041
+ raise Failure("gate_blocked", target)
1042
+ stage = posixpath.join(
1043
+ posixpath.dirname(target), ".kotonoha-" + planned["operation_id"] + ".tmp",
1044
+ )
1045
+ if planned["stage_target"] != stage:
1046
+ raise Failure("invalid_path", stage)
1047
+ report["operation_state"] = "approved"
1048
+ report["data"] = {
1049
+ "target": target, "after_sha256": None, "operation_id": planned["operation_id"],
1050
+ "changed": False, "verified": False,
1051
+ }
1052
+
1053
+ def read_at(parent, name):
1054
+ try:
1055
+ descriptor = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
1056
+ except OSError as error:
1057
+ if error.errno == 40:
1058
+ raise Failure("symlink_not_followed", target) from error
1059
+ raise
1060
+ with os.fdopen(descriptor, "rb") as stream:
1061
+ metadata = os.fstat(stream.fileno())
1062
+ if not stat.S_ISREG(metadata.st_mode):
1063
+ raise Failure("unsupported_format", target)
1064
+ maximum = maximum_note_bytes(request)
1065
+ if maximum is not None and metadata.st_size > maximum:
1066
+ raise Failure("limit_reached", target)
1067
+ data = stream.read() if maximum is None else stream.read(maximum + 1)
1068
+ if maximum is not None and len(data) > maximum:
1069
+ raise Failure("limit_reached", target)
1070
+ return data, metadata
1071
+
1072
+ with vault.parent(target) as (parent, name):
1073
+ local_filesystem(parent)
1074
+ if operation_id(planned, os.fstat(parent)) != planned["operation_id"]:
1075
+ raise Failure("parent_changed", target)
1076
+ expected_receipt = {
1077
+ "schema_version": 1, "operation_id": planned["operation_id"],
1078
+ "plan_sha256": digest(plan_bytes), "request_sha256": report["request_sha256"],
1079
+ "target": target, "after_sha256": digest(candidate), "verified": True,
1080
+ }
1081
+ receipt_path = operation / "receipt.json"
1082
+ try:
1083
+ receipt_bytes = workspace_read(request, receipt_path)
1084
+ except FileNotFoundError:
1085
+ receipt_bytes = None
1086
+ if receipt_bytes is not None:
1087
+ try:
1088
+ receipt = strict_json(receipt_bytes)
1089
+ validate(receipt, "Receipt")
1090
+ except (ValueError, ValidationError) as error:
1091
+ raise Failure("commit_outcome_uncertain", target) from error
1092
+ if receipt != expected_receipt:
1093
+ raise Failure("commit_outcome_uncertain", target)
1094
+ if not vault.allowed(target):
1095
+ raise Failure("outside_scope", target)
1096
+ try:
1097
+ current, _ = read_at(parent, name)
1098
+ except FileNotFoundError as error:
1099
+ raise Failure("target_changed", target) from error
1100
+ if digest(current) != planned["after_sha256"]:
1101
+ raise Failure("target_changed", target)
1102
+ report.update(operation_state="saved", write_status="saved")
1103
+ report["data"].update(after_sha256=digest(current), changed=False, verified=True)
1104
+ report["issues"].append(issue("already_applied", target))
1105
+ return
1106
+ result_path = operation / "result.json"
1107
+ try:
1108
+ previous = strict_json(workspace_read(request, result_path))
1109
+ except FileNotFoundError:
1110
+ previous = None
1111
+ if previous is not None:
1112
+ validate(previous, "Report")
1113
+ if previous["write_status"] in {"unknown", "saved"}:
1114
+ raise Failure("commit_outcome_uncertain", target)
1115
+ try:
1116
+ current_metadata = os.stat(name, dir_fd=parent, follow_symlinks=False)
1117
+ except FileNotFoundError:
1118
+ current_metadata = None
1119
+ metadata = None
1120
+ if planned["kind"] == "create" and current_metadata is not None:
1121
+ if vault.allowed(target) and stat.S_ISREG(current_metadata.st_mode):
1122
+ current, _ = read_at(parent, name)
1123
+ if digest(current) == planned["after_sha256"]:
1124
+ raise Failure("commit_outcome_uncertain", target)
1125
+ raise Failure("target_exists", target)
1126
+ if planned["kind"] == "edit":
1127
+ if current_metadata is None:
1128
+ raise Failure("target_changed", target)
1129
+ current, metadata = vault.read(target, maximum_note_bytes(request))
1130
+ if digest(current) == planned["after_sha256"]:
1131
+ raise Failure("commit_outcome_uncertain", target)
1132
+ if digest(current) != planned["before_sha256"]:
1133
+ raise Failure("target_changed", target)
1134
+ with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as stream:
1135
+ metadata_supported(os.fstat(stream.fileno()), stream.fileno())
1136
+ try:
1137
+ os.stat(PurePosixPath(stage).name, dir_fd=parent, follow_symlinks=False)
1138
+ except FileNotFoundError:
1139
+ pass
1140
+ else:
1141
+ raise Failure("target_exists", stage)
1142
+ pending = dict(report, status="failed", exit_code=1, write_status="unknown",
1143
+ issues=[issue("commit_outcome_uncertain", target)])
1144
+ replace_private(result_path, canonical(pending))
1145
+ committed, own_stage = False, False
1146
+ stage_name = PurePosixPath(stage).name
1147
+ try:
1148
+ try:
1149
+ descriptor = os.open(
1150
+ stage_name, os.O_RDWR | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
1151
+ 0o666 if planned["kind"] == "create" else 0o600, dir_fd=parent,
1152
+ )
1153
+ except FileExistsError as error:
1154
+ raise Failure("target_exists", stage) from error
1155
+ own_stage = True
1156
+ with os.fdopen(descriptor, "w+b") as stream:
1157
+ stream.write(candidate)
1158
+ stream.flush()
1159
+ if metadata is not None:
1160
+ os.fchown(stream.fileno(), metadata.st_uid, metadata.st_gid)
1161
+ os.fchmod(stream.fileno(), stat.S_IMODE(metadata.st_mode))
1162
+ os.fsync(stream.fileno())
1163
+ current_root = directory(vault.root)
1164
+ try:
1165
+ if identity(os.fstat(current_root)) != planned["vault_identity"]:
1166
+ raise Failure("parent_changed", target)
1167
+ finally:
1168
+ os.close(current_root)
1169
+ with vault.parent(target) as (current_parent, _):
1170
+ if identity(os.fstat(current_parent)) != identity(os.fstat(parent)):
1171
+ raise Failure("parent_changed", target)
1172
+ if before is not None:
1173
+ current, current_metadata = read_at(parent, name)
1174
+ if digest(current) != planned["before_sha256"]:
1175
+ raise Failure("target_changed", target)
1176
+ if (identity(current_metadata) != identity(metadata)
1177
+ or current_metadata.st_mode != metadata.st_mode
1178
+ or current_metadata.st_gid != metadata.st_gid):
1179
+ raise Failure("target_changed", target)
1180
+ with os.fdopen(os.open(name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=parent), "rb") as check:
1181
+ metadata_supported(os.fstat(check.fileno()), check.fileno())
1182
+ os.replace(stage_name, name, src_dir_fd=parent, dst_dir_fd=parent)
1183
+ committed, own_stage = True, False
1184
+ else:
1185
+ try:
1186
+ exclusive_create(parent, stage_name, name)
1187
+ except FileExistsError as error:
1188
+ raise Failure("target_exists", target) from error
1189
+ committed = True
1190
+ os.fsync(parent)
1191
+ verified, _ = read_at(parent, name)
1192
+ if digest(verified) != planned["after_sha256"]:
1193
+ raise OSError("Post-commit verification failed")
1194
+ if own_stage:
1195
+ os.unlink(stage_name, dir_fd=parent)
1196
+ own_stage = False
1197
+ os.fsync(parent)
1198
+ replace_private(receipt_path, canonical(expected_receipt))
1199
+ report.update(operation_state="saved", write_status="saved")
1200
+ report["data"].update(
1201
+ after_sha256=digest(verified), changed=True, verified=True,
1202
+ )
1203
+ report["issues"].append(issue("saved", target))
1204
+ replace_private(result_path, canonical(report))
1205
+ except (OSError, Failure) as error:
1206
+ report["issues"] = [item for item in report["issues"] if item["code"] != "saved"]
1207
+ if own_stage:
1208
+ try:
1209
+ os.unlink(stage_name, dir_fd=parent)
1210
+ except OSError as cleanup_error:
1211
+ report["issues"].append(issue("runtime_error", stage))
1212
+ if not committed:
1213
+ report.update(status="failed", exit_code=1, operation_state="blocked")
1214
+ report["issues"].append(issue("write_failed_before_commit", target))
1215
+ raise Failure("write_failed_before_commit", target, status="failed", exit_code=1) from cleanup_error
1216
+ report.update(
1217
+ status="failed" if committed or not isinstance(error, Failure) else error.status,
1218
+ exit_code=1 if committed or not isinstance(error, Failure) else error.exit_code,
1219
+ operation_state="blocked", write_status="unknown" if committed else "unsaved",
1220
+ )
1221
+ report["data"].update(changed=None if committed else False, verified=False,
1222
+ after_sha256=None)
1223
+ code = "commit_outcome_uncertain" if committed else error.code if isinstance(error, Failure) else "write_failed_before_commit"
1224
+ report["issues"].append(issue(code, target))
1225
+ try:
1226
+ replace_private(result_path, canonical(report))
1227
+ except (OSError, Failure):
1228
+ report["issues"].append(issue("runtime_error", str(result_path)))
1229
+ return
1230
+
1231
+
1232
+ class Parser(argparse.ArgumentParser):
1233
+ def error(self, message):
1234
+ raise Failure("invalid_request", status="failed", exit_code=1)
1235
+
1236
+
1237
+ def arguments(argv):
1238
+ parser = Parser(description=__doc__)
1239
+ commands = parser.add_subparsers(dest="command", required=True, parser_class=Parser)
1240
+ for name, command in CONTRACT["x-commands"].items():
1241
+ child = commands.add_parser(name)
1242
+ for flag in command["required"]:
1243
+ child.add_argument(flag, required=True)
1244
+ for flag in command["optional"]:
1245
+ child.add_argument(flag)
1246
+ return parser.parse_args(argv)
1247
+
1248
+
1249
+ def run(argv):
1250
+ command = argv[0] if argv and argv[0] in CONTRACT["x-commands"] else "inspect"
1251
+ report = {
1252
+ "schema_version": 1, "command": command, "status": "completed", "exit_code": 0,
1253
+ "write_status": "unsaved" if command in {"plan", "apply"} else "not_requested",
1254
+ "operation_state": "scoped", "request_sha256": None,
1255
+ "coverage": {"whole_vault": False, "enumeration_complete": True,
1256
+ "reads_complete": True, "parses_complete": True,
1257
+ "processed": [], "unprocessed": []},
1258
+ "issues": [], "data": None,
1259
+ }
1260
+ vault = None
1261
+ try:
1262
+ args = arguments(argv)
1263
+ request_bytes = Path(args.request).read_bytes()
1264
+ report["request_sha256"] = digest(request_bytes)
1265
+ request = strict_json(request_bytes)
1266
+ validate(request, "Request")
1267
+ if not request["consent"]["model_context"]:
1268
+ raise Failure("consent_missing")
1269
+ if sys.platform != "linux" or not hasattr(os, "O_NOFOLLOW"):
1270
+ raise Failure("capability_missing", exit_code=4)
1271
+ vault = Vault(request)
1272
+ report["coverage"]["whole_vault"] = ("directory", ".") in vault.scopes
1273
+ if command == "inspect":
1274
+ inspect(request, vault, report)
1275
+ elif command == "read":
1276
+ data, _ = vault.read(args.path, maximum_note_bytes(request))
1277
+ report["coverage"]["processed"] = [relative(args.path)]
1278
+ report["data"] = {"path": relative(args.path), "sha256": digest(data),
1279
+ "bytes": len(data), "content": data.decode("utf-8")}
1280
+ else:
1281
+ if command == "apply":
1282
+ write_capabilities()
1283
+ fcntl.flock(vault.fd, fcntl.LOCK_EX)
1284
+ try:
1285
+ if command == "plan":
1286
+ plan(request, vault, report, args)
1287
+ else:
1288
+ apply(request, vault, report, args)
1289
+ finally:
1290
+ fcntl.flock(vault.fd, fcntl.LOCK_UN)
1291
+ except Failure as error:
1292
+ report.update(status=error.status, exit_code=error.exit_code,
1293
+ operation_state="blocked")
1294
+ report["issues"].append(issue(error.code, error.path))
1295
+ except (ValueError, UnicodeError, ValidationError, OSError) as error:
1296
+ code = ("unsupported_format" if isinstance(error, UnicodeError)
1297
+ else "read_failed" if command == "read" and isinstance(error, OSError)
1298
+ else "invalid_request" if isinstance(error, (ValueError, ValidationError))
1299
+ else "runtime_error")
1300
+ report.update(status="failed", exit_code=1, operation_state="blocked")
1301
+ report["issues"].append(issue(code))
1302
+ finally:
1303
+ if vault is not None:
1304
+ vault.close()
1305
+ validate(report, "Report")
1306
+ return report
1307
+
1308
+
1309
+ def main():
1310
+ report = run(sys.argv[1:])
1311
+ print(json.dumps(report, ensure_ascii=False, allow_nan=False))
1312
+ return report["exit_code"]
1313
+
1314
+
1315
+ if __name__ == "__main__":
1316
+ sys.exit(main())