docsweep 0.1.0__py3-none-any.whl

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 (99) hide show
  1. docsweep/__init__.py +3 -0
  2. docsweep/__main__.py +4 -0
  3. docsweep/activity.py +183 -0
  4. docsweep/aggregate_index.py +148 -0
  5. docsweep/archive.py +80 -0
  6. docsweep/atomic.py +181 -0
  7. docsweep/auto_triage.py +172 -0
  8. docsweep/brief/__init__.py +9 -0
  9. docsweep/brief/score.py +132 -0
  10. docsweep/brief/service.py +243 -0
  11. docsweep/capture/__init__.py +15 -0
  12. docsweep/capture/heuristics.py +69 -0
  13. docsweep/capture/llm.py +152 -0
  14. docsweep/capture/models.py +29 -0
  15. docsweep/capture/service.py +79 -0
  16. docsweep/claim.py +112 -0
  17. docsweep/cli.py +1771 -0
  18. docsweep/completion.py +157 -0
  19. docsweep/config.py +418 -0
  20. docsweep/context.py +160 -0
  21. docsweep/cross/__init__.py +11 -0
  22. docsweep/cross/service.py +207 -0
  23. docsweep/detect.py +328 -0
  24. docsweep/engine.py +312 -0
  25. docsweep/export.py +287 -0
  26. docsweep/find.py +133 -0
  27. docsweep/graph/__init__.py +8 -0
  28. docsweep/graph/service.py +106 -0
  29. docsweep/index.py +521 -0
  30. docsweep/inject.py +654 -0
  31. docsweep/interactive.py +344 -0
  32. docsweep/linkcheck.py +231 -0
  33. docsweep/mcp_server.py +471 -0
  34. docsweep/migrate.py +167 -0
  35. docsweep/models.py +94 -0
  36. docsweep/presets.py +56 -0
  37. docsweep/related.py +175 -0
  38. docsweep/reports.py +315 -0
  39. docsweep/resurrect/__init__.py +12 -0
  40. docsweep/resurrect/embedding.py +43 -0
  41. docsweep/resurrect/service.py +184 -0
  42. docsweep/resurrect/similarity.py +38 -0
  43. docsweep/review.py +69 -0
  44. docsweep/scan.py +431 -0
  45. docsweep/security/__init__.py +13 -0
  46. docsweep/security/path.py +65 -0
  47. docsweep/server/__init__.py +5 -0
  48. docsweep/server/app.py +319 -0
  49. docsweep/server/routes/__init__.py +11 -0
  50. docsweep/server/routes/board.py +456 -0
  51. docsweep/server/routes/brief.py +66 -0
  52. docsweep/server/routes/capture.py +108 -0
  53. docsweep/server/routes/cards.py +436 -0
  54. docsweep/server/routes/cross.py +62 -0
  55. docsweep/server/routes/graph.py +54 -0
  56. docsweep/server/routes/resurrect.py +64 -0
  57. docsweep/server/sanitize.py +144 -0
  58. docsweep/server/security.py +47 -0
  59. docsweep/server/static/board.css +640 -0
  60. docsweep/server/static/dnd.js +116 -0
  61. docsweep/server/static/edit.js +258 -0
  62. docsweep/server/static/htmx.min.js +1 -0
  63. docsweep/server/static/icons/apple-touch-icon.png +0 -0
  64. docsweep/server/static/icons/favicon.ico +0 -0
  65. docsweep/server/static/icons/favicon.svg +30 -0
  66. docsweep/server/static/icons/icon-192.png +0 -0
  67. docsweep/server/static/icons/icon-512.png +0 -0
  68. docsweep/server/static/keymap.js +1123 -0
  69. docsweep/server/templates/_board_body.html +102 -0
  70. docsweep/server/templates/_card.html +68 -0
  71. docsweep/server/templates/_change_picker.html +27 -0
  72. docsweep/server/templates/_due_picker.html +13 -0
  73. docsweep/server/templates/_edit_pane.html +73 -0
  74. docsweep/server/templates/_label_picker.html +14 -0
  75. docsweep/server/templates/_preview.html +15 -0
  76. docsweep/server/templates/_settings.html +68 -0
  77. docsweep/server/templates/board.html +111 -0
  78. docsweep/server/templates/brief.html +89 -0
  79. docsweep/server/templates/capture.html +121 -0
  80. docsweep/server/templates/cross.html +74 -0
  81. docsweep/server/templates/graph.html +99 -0
  82. docsweep/server/templates/resurrect.html +58 -0
  83. docsweep/services/__init__.py +11 -0
  84. docsweep/services/archive.py +240 -0
  85. docsweep/services/content.py +68 -0
  86. docsweep/services/due.py +189 -0
  87. docsweep/services/frontmatter.py +241 -0
  88. docsweep/services/status.py +157 -0
  89. docsweep/stale.py +117 -0
  90. docsweep/state.py +198 -0
  91. docsweep/states.py +165 -0
  92. docsweep/templates_gen.py +178 -0
  93. docsweep/timeline.py +177 -0
  94. docsweep-0.1.0.dist-info/METADATA +484 -0
  95. docsweep-0.1.0.dist-info/RECORD +99 -0
  96. docsweep-0.1.0.dist-info/WHEEL +4 -0
  97. docsweep-0.1.0.dist-info/entry_points.txt +2 -0
  98. docsweep-0.1.0.dist-info/licenses/LICENSE +21 -0
  99. docsweep-0.1.0.dist-info/licenses/NOTICES.md +47 -0
docsweep/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """docsweep — AI 作業ドキュメント(plan/bugfix/pending)の横断スキャン・判定・archive 移送ツール。"""
2
+
3
+ __version__ = "0.1.0"
docsweep/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from .cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
docsweep/activity.py ADDED
@@ -0,0 +1,183 @@
1
+ """``docsweep activity`` — 過去に触ったもの/今後期限のものを日付でまとめる。
2
+
3
+ 新規永続化は一切行わない。``scan_records()`` が既に返す ``mtime``(過去日軸)と
4
+ ``due``(未来日軸)だけを使い、日付ごとにグルーピングする薄い読み取り専用ロジック。
5
+ today 自身は両軸を出す。plan_activity-summary.md C1 の主要 deliverable。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from datetime import date, datetime, timedelta, timezone
12
+ from pathlib import Path
13
+
14
+ from .brief.service import _detect_cwd_project, _resolve_target_projects
15
+ from .config import Config
16
+ from .engine import scan_records
17
+ from .models import FileRecord
18
+ from .services.due import DueParseError, resolve_relative_offset
19
+
20
+
21
+ class ActivityDateError(ValueError):
22
+ """``--date``/``--since``/``--until`` の指定が解釈できないときに発生。"""
23
+
24
+
25
+ def _resolve_date_token(token: str, *, today: date) -> date:
26
+ """``--date`` の 1 トークンを解決する(today/yesterday/tomorrow/YYYY-MM-DD)。"""
27
+ t = token.strip().lower()
28
+ if t == "today":
29
+ return today
30
+ if t == "yesterday":
31
+ return today - timedelta(days=1)
32
+ if t == "tomorrow":
33
+ return today + timedelta(days=1)
34
+ try:
35
+ return date.fromisoformat(token.strip())
36
+ except ValueError as e:
37
+ raise ActivityDateError(f"--date を解釈できません: {token!r}") from e
38
+
39
+
40
+ def resolve_target_dates(
41
+ dates: list[str] | None,
42
+ *,
43
+ since: str | None,
44
+ until: str | None,
45
+ today: date,
46
+ ) -> list[date]:
47
+ """CLI 引数から対象日付の一覧(昇順・重複無し)を組み立てる。
48
+
49
+ ``--since``/``--until`` と ``--date`` は和集合(両方指定されたら両方を含める)。
50
+ どちらも未指定なら既定(today + yesterday)。
51
+ """
52
+ out: set[date] = set()
53
+ if dates:
54
+ for tok in dates:
55
+ out.add(_resolve_date_token(tok, today=today))
56
+ if since or until:
57
+ try:
58
+ start = resolve_relative_offset(since, today=today) if since else today
59
+ end = resolve_relative_offset(until, today=today) if until else today
60
+ except DueParseError as e:
61
+ raise ActivityDateError(str(e)) from e
62
+ if start > end:
63
+ start, end = end, start
64
+ cur = start
65
+ while cur <= end:
66
+ out.add(cur)
67
+ cur += timedelta(days=1)
68
+ if not out:
69
+ out = {today, today - timedelta(days=1)}
70
+ return sorted(out)
71
+
72
+
73
+ def _axes_for_date(d: date, *, today: date) -> tuple[bool, bool]:
74
+ """(mtime 軸を出すか, due 軸を出すか) を返す。today 自身は両方 True。"""
75
+ if d < today:
76
+ return (True, False)
77
+ if d > today:
78
+ return (False, True)
79
+ return (True, True)
80
+
81
+
82
+ def _short_record(rec: FileRecord) -> dict:
83
+ return {
84
+ "path": rec.path,
85
+ "rel": Path(rec.path).name,
86
+ "project": rec.project,
87
+ "type": rec.type,
88
+ "state": rec.state,
89
+ "state_label": rec.state_label,
90
+ "title": rec.title,
91
+ "due": rec.due,
92
+ "age_days": rec.age_days,
93
+ }
94
+
95
+
96
+ @dataclass
97
+ class DateBucket:
98
+ """1 日分のグルーピング結果。"""
99
+
100
+ touched: list[dict] = field(default_factory=list) # mtime 軸(触ったもの)
101
+ due: list[dict] = field(default_factory=list) # due 軸(期限のもの)
102
+
103
+ def to_dict(self) -> dict:
104
+ return {"touched": list(self.touched), "due": list(self.due)}
105
+
106
+
107
+ @dataclass
108
+ class ActivityResult:
109
+ """activity の最終出力。CLI/JSON 共通。"""
110
+
111
+ generated_at: str
112
+ today: str
113
+ dates: dict[str, DateBucket] = field(default_factory=dict)
114
+
115
+ def to_dict(self) -> dict:
116
+ return {
117
+ "generated_at": self.generated_at,
118
+ "today": self.today,
119
+ "dates": {k: v.to_dict() for k, v in self.dates.items()},
120
+ }
121
+
122
+
123
+ def build_activity(
124
+ config: Config,
125
+ *,
126
+ dates: list[str] | None = None,
127
+ since: str | None = None,
128
+ until: str | None = None,
129
+ project: str | None = None,
130
+ all_projects: bool = False,
131
+ today: date | None = None,
132
+ ) -> ActivityResult:
133
+ """activity を 1 回ぶん組み立てて返す。
134
+
135
+ Args:
136
+ config: ロード済み Config
137
+ dates: ``--date`` の複数指定(today/yesterday/tomorrow/YYYY-MM-DD)
138
+ since: ``--since``(絶対 or 符号付き相対オフセット)
139
+ until: ``--until``(同上)
140
+ project: 単一プロジェクト指定(``project_id`` 文字列)
141
+ all_projects: True で search_paths の全プロジェクトを束ねる
142
+ today: テスト用の日付固定。未指定なら ``date.today()``
143
+ """
144
+ now = datetime.now(timezone.utc).astimezone()
145
+ today_date = today or now.date()
146
+
147
+ target_dates = resolve_target_dates(dates, since=since, until=until, today=today_date)
148
+
149
+ records = scan_records(config)
150
+ cwd_proj = _detect_cwd_project(config) if not (project or all_projects) else None
151
+ targets = _resolve_target_projects(
152
+ records, project=project, all_projects=all_projects, cwd_project=cwd_proj,
153
+ )
154
+ scoped = [r for r in records if r.project in targets]
155
+
156
+ buckets: dict[str, DateBucket] = {}
157
+ for d in target_dates:
158
+ want_mtime, want_due = _axes_for_date(d, today=today_date)
159
+ bucket = DateBucket()
160
+ if want_mtime:
161
+ bucket.touched = [
162
+ _short_record(r) for r in scoped
163
+ if r.mtime and datetime.fromtimestamp(r.mtime).astimezone().date() == d
164
+ ]
165
+ bucket.touched.sort(key=lambda x: (x["project"] or "", x["rel"]))
166
+ if want_due:
167
+ for r in scoped:
168
+ if not r.due:
169
+ continue
170
+ try:
171
+ due_date = date.fromisoformat(r.due)
172
+ except ValueError:
173
+ continue
174
+ if due_date == d:
175
+ bucket.due.append(_short_record(r))
176
+ bucket.due.sort(key=lambda x: (x["project"] or "", x["rel"]))
177
+ buckets[d.isoformat()] = bucket
178
+
179
+ return ActivityResult(
180
+ generated_at=now.isoformat(),
181
+ today=today_date.isoformat(),
182
+ dates=buckets,
183
+ )
@@ -0,0 +1,148 @@
1
+ """横断集約 INDEX(develop 全体を 1 か所で把握)— C5。
2
+
3
+ 実ファイルは各プロジェクトに残したまま、スキャンルート直下に論理集約の INDEX を生成する。
4
+ - INDEX.json: AI エージェント連携用(ステータス別・pending 常設・要判断/要修正)
5
+ - INDEX.md: 人間がエディタで一覧
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ from dataclasses import asdict, dataclass
12
+ from pathlib import Path
13
+
14
+ from .config import Config
15
+ from .engine import ScanResult, run_scan, scan_records
16
+ from .models import Flag
17
+
18
+ INDEX_DIRNAME = ".docsweep"
19
+
20
+
21
+ @dataclass
22
+ class IndexData:
23
+ roots: list[str]
24
+ counts: dict
25
+ by_state: dict[str, list[dict]]
26
+ pending: list[dict]
27
+ needs_decision: list[dict]
28
+ needs_fix: list[dict]
29
+ overdue_todo: list[dict] = None # type: ignore[assignment]
30
+ overdue_graduate: list[dict] = None # type: ignore[assignment]
31
+
32
+ def __post_init__(self):
33
+ if self.overdue_todo is None:
34
+ self.overdue_todo = []
35
+ if self.overdue_graduate is None:
36
+ self.overdue_graduate = []
37
+
38
+ def to_dict(self) -> dict:
39
+ return asdict(self)
40
+
41
+
42
+ def build_index(config: Config, result: ScanResult | None = None) -> IndexData:
43
+ # 引数で ScanResult が渡されていれば優先(書き込み系から呼ぶ古い経路)。
44
+ # 渡されなければ索引フォールバック付きの scan_records を使う(fast path)。
45
+ if result is not None:
46
+ recs = result.records
47
+ else:
48
+ recs = scan_records(config)
49
+
50
+ by_state: dict[str, list[dict]] = {}
51
+ for r in recs:
52
+ by_state.setdefault(r.state or "unknown", []).append(r.to_dict())
53
+ for v in by_state.values():
54
+ v.sort(key=lambda d: d["age_days"], reverse=True)
55
+
56
+ pending = [r.to_dict() for r in recs if r.state == "pending"]
57
+ needs_decision = sorted(
58
+ [r.to_dict() for r in recs if Flag.NEEDS_DECISION.value in r.flags],
59
+ key=lambda d: d["age_days"], reverse=True,
60
+ )
61
+ needs_fix = [r.to_dict() for r in recs if Flag.NEEDS_FIX.value in r.flags]
62
+ overdue_todo = sorted(
63
+ [r.to_dict() for r in recs if Flag.OVERDUE_TODO.value in r.flags],
64
+ key=lambda d: d.get("due") or "",
65
+ )
66
+ overdue_graduate = sorted(
67
+ [r.to_dict() for r in recs if Flag.OVERDUE_GRADUATE.value in r.flags],
68
+ key=lambda d: d.get("due") or "",
69
+ )
70
+
71
+ counts = {
72
+ "total": len(recs),
73
+ "projects": len({r.project for r in recs}),
74
+ "needs_decision": len(needs_decision),
75
+ "needs_fix": len(needs_fix),
76
+ "pending": len(pending),
77
+ "archivable": sum(1 for r in recs if r.auto_movable and r.archivable),
78
+ "overdue_todo": len(overdue_todo),
79
+ "overdue_graduate": len(overdue_graduate),
80
+ }
81
+ return IndexData(
82
+ roots=[str(r) for r in config.roots],
83
+ counts=counts,
84
+ by_state=by_state,
85
+ pending=pending,
86
+ needs_decision=needs_decision,
87
+ needs_fix=needs_fix,
88
+ overdue_todo=overdue_todo,
89
+ overdue_graduate=overdue_graduate,
90
+ )
91
+
92
+
93
+ def _render_row(d: dict) -> str:
94
+ name = Path(d["path"]).name
95
+ label = d.get("state_label") or "[?]"
96
+ summary = f" — {d['summary']}" if d.get("summary") else ""
97
+ flags = f" `{','.join(d['flags'])}`" if d.get("flags") else ""
98
+ return f"- {label} **{d['project']}**/{name} · {d['age_days']}d{flags}{summary}"
99
+
100
+
101
+ def render_markdown(idx: IndexData, state_model) -> str:
102
+ c = idx.counts
103
+ lines: list[str] = [
104
+ "# docsweep INDEX",
105
+ "",
106
+ f"> 横断集約: {c['projects']} プロジェクト / {c['total']} 件 = "
107
+ f"要判断 {c['needs_decision']} · 要修正 {c['needs_fix']} · 保留 {c['pending']} · archive候補 {c['archivable']}",
108
+ "",
109
+ ]
110
+ if idx.needs_decision:
111
+ lines += ["## ⚠ 要判断(陳腐化)", ""]
112
+ lines += [_render_row(d) for d in idx.needs_decision]
113
+ lines += [""]
114
+ if idx.pending:
115
+ lines += ["## 💤 保留(pending)", ""]
116
+ lines += [_render_row(d) for d in idx.pending]
117
+ lines += [""]
118
+ if idx.needs_fix:
119
+ lines += ["## 🔧 要修正(ラベル欠落・パース不能)", ""]
120
+ lines += [_render_row(d) for d in idx.needs_fix]
121
+ lines += [""]
122
+
123
+ lines += ["## ステータス別", ""]
124
+ for key in sorted(idx.by_state):
125
+ recs = idx.by_state[key]
126
+ st = state_model.by_key(key) if state_model else None
127
+ label = f"[{st.label()}]" if st else f"[{key}]"
128
+ lines += [f"### {label} ({len(recs)})", ""]
129
+ lines += [_render_row(d) for d in recs]
130
+ lines += [""]
131
+ return "\n".join(lines).rstrip() + "\n"
132
+
133
+
134
+ def index_dir(config: Config) -> Path:
135
+ base = config.roots[0] if config.roots else Path.cwd()
136
+ return Path(base) / INDEX_DIRNAME
137
+
138
+
139
+ def write_index(config: Config, result: ScanResult | None = None) -> tuple[Path, Path]:
140
+ """INDEX.json / INDEX.md をスキャンルート直下の .docsweep/ に書き出す。"""
141
+ idx = build_index(config, result)
142
+ out_dir = index_dir(config)
143
+ out_dir.mkdir(parents=True, exist_ok=True)
144
+ json_path = out_dir / "INDEX.json"
145
+ md_path = out_dir / "INDEX.md"
146
+ json_path.write_text(json.dumps(idx.to_dict(), ensure_ascii=False, indent=2), encoding="utf-8")
147
+ md_path.write_text(render_markdown(idx, config.state_model), encoding="utf-8")
148
+ return json_path, md_path
docsweep/archive.py ADDED
@@ -0,0 +1,80 @@
1
+ """archive 移送と移動ログ JSONL。
2
+
3
+ - 場所は config 可変(既定 archive/)。同名衝突は連番(_2)。
4
+ - 移動ログ {ts, op, project, status, src, dst} を JSONL 追記(eject/復元の土台)。
5
+ - 同一ボリューム前提に依存しない(shutil.move で吸収)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import shutil
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+
15
+ from .models import MoveLogEntry
16
+
17
+ MOVE_LOG_NAME = "moves.jsonl"
18
+
19
+
20
+ def _now_iso() -> str:
21
+ return datetime.now().astimezone().isoformat(timespec="seconds")
22
+
23
+
24
+ def dedupe_path(dst: Path) -> Path:
25
+ """衝突時に stem に _2, _3... を付けて空きパスを返す。"""
26
+ if not dst.exists():
27
+ return dst
28
+ stem, suffix, parent = dst.stem, dst.suffix, dst.parent
29
+ n = 2
30
+ while True:
31
+ cand = parent / f"{stem}_{n}{suffix}"
32
+ if not cand.exists():
33
+ return cand
34
+ n += 1
35
+
36
+
37
+ def move_log_path(root: Path) -> Path:
38
+ return root / ".docsweep" / MOVE_LOG_NAME
39
+
40
+
41
+ def append_move_log(root: Path, entry: MoveLogEntry) -> None:
42
+ p = move_log_path(root)
43
+ p.parent.mkdir(parents=True, exist_ok=True)
44
+ with p.open("a", encoding="utf-8") as fh:
45
+ fh.write(json.dumps(entry.to_dict(), ensure_ascii=False) + "\n")
46
+
47
+
48
+ def archive_file(
49
+ *,
50
+ src: Path,
51
+ project_dir: Path,
52
+ archive_dir: str,
53
+ root: Path,
54
+ project: str,
55
+ status: str | None,
56
+ op: str = "archive",
57
+ dry_run: bool = False,
58
+ batch_id: str | None = None,
59
+ ) -> Path:
60
+ """src を project_dir/<archive_dir>/ へ移送し、移動ログに記録する。移送先を返す。
61
+
62
+ ``batch_id`` を与えると JSONL の同名フィールドに記録され、後で Undo で逆引きできる。
63
+ """
64
+ src = src.resolve()
65
+ dest_dir = (project_dir / archive_dir).resolve()
66
+ dst = dedupe_path(dest_dir / src.name)
67
+
68
+ if dry_run:
69
+ return dst
70
+
71
+ dest_dir.mkdir(parents=True, exist_ok=True)
72
+ shutil.move(str(src), str(dst))
73
+ append_move_log(
74
+ root,
75
+ MoveLogEntry(
76
+ ts=_now_iso(), op=op, project=project, status=status,
77
+ src=src.as_posix(), dst=dst.as_posix(), batch_id=batch_id,
78
+ ),
79
+ )
80
+ return dst
docsweep/atomic.py ADDED
@@ -0,0 +1,181 @@
1
+ """アトミック書き込み + 楽観ロック(mtime check) + バックアップ。
2
+
3
+ 全ての書き込み API はこのヘルパ経由で MD を更新する。Web UI と MCP どちらから書いても安全。
4
+
5
+ - アトミック書き込み: 同ディレクトリに一時ファイル作成 → ``os.replace`` で差し替え。
6
+ Windows でも ``os.replace`` は atomic(NTFS の ``MoveFileEx`` 経由)。
7
+ - 楽観ロック: ``expected_mtime`` を任意引数で受け取り、不一致なら ``ConflictError``。
8
+ Web UI からは必ず送る(読み込み時の mtime を保持)。CLI / MCP は省略可。
9
+ - バックアップ: 全書き込み前に ``.docsweep/backup/`` へコピー。既定 30 日保持・自動掃除。
10
+ - 行単位編集 vs 全置換:
11
+ - ``update_line`` は frontmatter `due:` 1 行や H1 ラベル先頭だけを置換(本文を触らない)
12
+ - ``write_atomic`` は全置換(Web UI の本文編集ペインから呼ぶ)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import os
18
+ import shutil
19
+ import tempfile
20
+ import time
21
+ from pathlib import Path
22
+
23
+ BACKUP_DIR_NAME = "backup"
24
+ BACKUP_RETENTION_SECONDS = 30 * 24 * 60 * 60 # 30 日
25
+
26
+
27
+ class ConflictError(Exception):
28
+ """expected_mtime と実 mtime が一致しないときに発生(楽観ロックの不一致)。"""
29
+
30
+ def __init__(self, path: Path, expected: float, actual: float) -> None:
31
+ super().__init__(
32
+ f"mtime conflict at {path}: expected={expected!r} actual={actual!r}"
33
+ )
34
+ self.path = path
35
+ self.expected = expected
36
+ self.actual = actual
37
+
38
+
39
+ def _mtime(path: Path) -> float:
40
+ return path.stat().st_mtime
41
+
42
+
43
+ def _project_root_for(path: Path) -> Path | None:
44
+ """書き込み対象 path から ``.docsweep/`` を置くプロジェクト境界を辿る。
45
+
46
+ ``backup`` の置き場決めにだけ使うので、見つからなければ path の親で代用する
47
+ (バックアップを取らないより取った方が安全という選択)。
48
+ """
49
+ cur = path.parent.resolve()
50
+ while True:
51
+ for marker in (".docsweep.yaml", ".git", "pyproject.toml", "package.json"):
52
+ if (cur / marker).exists():
53
+ return cur
54
+ parent = cur.parent
55
+ if parent == cur:
56
+ return None
57
+ cur = parent
58
+
59
+
60
+ def backup_dir_for(path: Path) -> Path:
61
+ root = _project_root_for(path) or path.parent
62
+ return root / ".docsweep" / BACKUP_DIR_NAME
63
+
64
+
65
+ def backup(path: Path) -> Path | None:
66
+ """書き込み直前に呼ぶ。``.docsweep/backup/<filename>.<unix_ts>`` へコピー。
67
+
68
+ 対象が存在しない(新規作成)場合は何もしないで None を返す。古いバックアップは
69
+ 呼び出しのついでに掃除する(30 日超で自動削除)。
70
+ """
71
+ if not path.is_file():
72
+ return None
73
+ dst_dir = backup_dir_for(path)
74
+ dst_dir.mkdir(parents=True, exist_ok=True)
75
+ ts = int(time.time())
76
+ dst = dst_dir / f"{path.name}.{ts}"
77
+ shutil.copy2(str(path), str(dst))
78
+ _cleanup_backups(dst_dir)
79
+ return dst
80
+
81
+
82
+ def _cleanup_backups(dst_dir: Path) -> None:
83
+ """30 日経過したバックアップを削除する(best-effort・失敗無視)。"""
84
+ cutoff = time.time() - BACKUP_RETENTION_SECONDS
85
+ try:
86
+ for entry in dst_dir.iterdir():
87
+ try:
88
+ if entry.is_file() and entry.stat().st_mtime < cutoff:
89
+ entry.unlink()
90
+ except OSError:
91
+ pass
92
+ except OSError:
93
+ pass
94
+
95
+
96
+ def write_atomic(
97
+ path: Path,
98
+ content: str,
99
+ *,
100
+ expected_mtime: float | None = None,
101
+ encoding: str = "utf-8",
102
+ take_backup: bool = True,
103
+ ) -> float:
104
+ """``path`` の内容を ``content`` で全置換する。書き込み後の新 mtime を返す。
105
+
106
+ ``expected_mtime`` が指定され、実 mtime と一致しなければ ``ConflictError``。
107
+ ``take_backup=True`` のとき、既存ファイルを `.docsweep/backup/` へ退避する。
108
+ """
109
+ path = Path(path)
110
+ if expected_mtime is not None and path.is_file():
111
+ actual = _mtime(path)
112
+ if not _mtime_close(actual, expected_mtime):
113
+ raise ConflictError(path, expected_mtime, actual)
114
+ if take_backup:
115
+ backup(path)
116
+ path.parent.mkdir(parents=True, exist_ok=True)
117
+ fd, tmp_name = tempfile.mkstemp(
118
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
119
+ )
120
+ try:
121
+ with os.fdopen(fd, "w", encoding=encoding, newline="") as fh:
122
+ fh.write(content)
123
+ os.replace(tmp_name, str(path))
124
+ except Exception:
125
+ # 一時ファイルが残らないよう片付ける。
126
+ try:
127
+ os.unlink(tmp_name)
128
+ except OSError:
129
+ pass
130
+ raise
131
+ return _mtime(path)
132
+
133
+
134
+ def update_line(
135
+ path: Path,
136
+ *,
137
+ transform,
138
+ expected_mtime: float | None = None,
139
+ take_backup: bool = True,
140
+ ) -> float:
141
+ """``transform(text) -> new_text`` を経由して 1 ファイルを書き換える。
142
+
143
+ 呼び出し側の transform 関数が「frontmatter `due:` を 1 行だけ差し替える」「H1 行先頭の
144
+ ラベルだけ差し替える」のような行単位の正規表現置換を行うため、本ヘルパは
145
+ アトミック書き込み・楽観ロック・バックアップだけを担う。
146
+ """
147
+ path = Path(path)
148
+ if expected_mtime is not None and path.is_file():
149
+ actual = _mtime(path)
150
+ if not _mtime_close(actual, expected_mtime):
151
+ raise ConflictError(path, expected_mtime, actual)
152
+ text = path.read_text(encoding="utf-8", newline="")
153
+ new_text = transform(text)
154
+ if new_text == text:
155
+ # 変更なし。書き込みも backup も省略する。
156
+ return _mtime(path)
157
+ if take_backup:
158
+ backup(path)
159
+ fd, tmp_name = tempfile.mkstemp(
160
+ prefix=f".{path.name}.", suffix=".tmp", dir=str(path.parent)
161
+ )
162
+ try:
163
+ with os.fdopen(fd, "w", encoding="utf-8", newline="") as fh:
164
+ fh.write(new_text)
165
+ os.replace(tmp_name, str(path))
166
+ except Exception:
167
+ try:
168
+ os.unlink(tmp_name)
169
+ except OSError:
170
+ pass
171
+ raise
172
+ return _mtime(path)
173
+
174
+
175
+ def _mtime_close(a: float, b: float, *, tol: float = 0.001) -> bool:
176
+ """ファイルシステムの mtime 解像度(Windows FAT は 2 秒・NTFS は 100ns)を考慮した近似比較。
177
+
178
+ そのまま `==` で比較すると、JSON 経由で round-trip した mtime と実 mtime が
179
+ sub-millisecond オーダーで微妙にズレて誤検出する。1ms 以内なら同一とみなす。
180
+ """
181
+ return abs(a - b) < tol