lanternspec 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 (118) hide show
  1. lanternspec/__init__.py +8 -0
  2. lanternspec/__main__.py +3 -0
  3. lanternspec/check/__init__.py +4 -0
  4. lanternspec/check/check_docs.py +468 -0
  5. lanternspec/check/doctor.py +113 -0
  6. lanternspec/check/githooks.py +284 -0
  7. lanternspec/check/legacy.py +116 -0
  8. lanternspec/check/templates.py +505 -0
  9. lanternspec/cli/__init__.py +53 -0
  10. lanternspec/cli/check.py +184 -0
  11. lanternspec/cli/common.py +31 -0
  12. lanternspec/cli/core.py +252 -0
  13. lanternspec/cli/learn.py +164 -0
  14. lanternspec/cli/report.py +124 -0
  15. lanternspec/cli/tools.py +116 -0
  16. lanternspec/cli/work.py +441 -0
  17. lanternspec/core/__init__.py +5 -0
  18. lanternspec/core/enums.py +457 -0
  19. lanternspec/core/frontmatter.py +150 -0
  20. lanternspec/core/links.py +100 -0
  21. lanternspec/core/meta.py +82 -0
  22. lanternspec/core/new_id.py +74 -0
  23. lanternspec/core/principles.py +457 -0
  24. lanternspec/core/profile.py +524 -0
  25. lanternspec/core/project.py +226 -0
  26. lanternspec/core/state.py +51 -0
  27. lanternspec/core/taskfile.py +163 -0
  28. lanternspec/core/templating.py +133 -0
  29. lanternspec/learn/__init__.py +4 -0
  30. lanternspec/learn/debt.py +411 -0
  31. lanternspec/learn/knowledge/__init__.py +67 -0
  32. lanternspec/learn/knowledge/drafts.py +75 -0
  33. lanternspec/learn/knowledge/mcp_server.py +123 -0
  34. lanternspec/learn/knowledge/notion.py +157 -0
  35. lanternspec/learn/knowledge/obsidian.py +118 -0
  36. lanternspec/report/__init__.py +6 -0
  37. lanternspec/report/doc_map.py +181 -0
  38. lanternspec/report/evaluate.py +174 -0
  39. lanternspec/report/harness.py +127 -0
  40. lanternspec/report/principles_doc.py +134 -0
  41. lanternspec/report/scan.py +299 -0
  42. lanternspec/report/status.py +75 -0
  43. lanternspec/report/views.py +352 -0
  44. lanternspec/resources/__init__.py +23 -0
  45. lanternspec/resources/agents/AGENTS.md +55 -0
  46. lanternspec/resources/principles/conventions/go.yaml +53 -0
  47. lanternspec/resources/principles/conventions/python.yaml +82 -0
  48. lanternspec/resources/principles/conventions/rust.yaml +60 -0
  49. lanternspec/resources/principles/conventions/swift.yaml +59 -0
  50. lanternspec/resources/principles/conventions/typescript.yaml +83 -0
  51. lanternspec/resources/principles/principles.yaml +189 -0
  52. lanternspec/resources/skills/l-adr/SKILL.md +30 -0
  53. lanternspec/resources/skills/l-build/SKILL.md +100 -0
  54. lanternspec/resources/skills/l-check/SKILL.md +30 -0
  55. lanternspec/resources/skills/l-design/SKILL.md +78 -0
  56. lanternspec/resources/skills/l-idea/SKILL.md +52 -0
  57. lanternspec/resources/skills/l-init/SKILL.md +116 -0
  58. lanternspec/resources/skills/l-learn/SKILL.md +87 -0
  59. lanternspec/resources/skills/l-map/SKILL.md +20 -0
  60. lanternspec/resources/skills/l-plan/SKILL.md +79 -0
  61. lanternspec/resources/skills/l-report/SKILL.md +44 -0
  62. lanternspec/resources/skills/l-sync/SKILL.md +42 -0
  63. lanternspec/resources/skills/l-task/SKILL.md +46 -0
  64. lanternspec/resources/skills/l-template/SKILL.md +61 -0
  65. lanternspec/resources/templates/ADR.md +45 -0
  66. lanternspec/resources/templates/API.md +34 -0
  67. lanternspec/resources/templates/_meta/enums.yaml +188 -0
  68. lanternspec/resources/templates//352/260/234/353/260/234/353/254/270/354/204/234 MOC.md" +26 -0
  69. lanternspec/resources/templates//352/270/260/353/212/245 /353/252/251/353/241/235.md" +30 -0
  70. lanternspec/resources/templates//352/270/260/353/241/235.md +45 -0
  71. lanternspec/resources/templates//352/270/260/354/241/264 /353/254/270/354/204/234 /354/247/200/353/217/204.md" +31 -0
  72. lanternspec/resources/templates//352/270/260/355/232/215.md +59 -0
  73. lanternspec/resources/templates//353/215/260/354/235/264/355/204/260.md +38 -0
  74. lanternspec/resources/templates//353/260/260/355/217/254.md +45 -0
  75. lanternspec/resources/templates//353/266/200/354/261/204 /354/271/264/353/223/234.md" +46 -0
  76. lanternspec/resources/templates//354/204/234/353/262/204 /353/260/260/355/217/254 /302/267 /354/232/264/354/230/201.md" +33 -0
  77. lanternspec/resources/templates//354/204/244/352/263/204.md +61 -0
  78. lanternspec/resources/templates//354/204/270/354/205/230 /352/270/260/353/241/235.md" +27 -0
  79. lanternspec/resources/templates//354/213/234/354/212/244/355/205/234 /352/265/254/354/241/260.md" +41 -0
  80. lanternspec/resources/templates//354/225/204/353/212/224 /352/270/260/354/210/240.md" +25 -0
  81. lanternspec/resources/templates//354/225/204/354/235/264/353/224/224/354/226/264.md +44 -0
  82. lanternspec/resources/templates//354/227/255/353/237/211 /354/271/264/353/223/234.md" +42 -0
  83. lanternspec/resources/templates//354/236/221/354/227/205 /353/213/250/354/234/204 /352/270/260/353/241/235.md" +27 -0
  84. lanternspec/resources/templates//355/224/204/353/241/234/354/240/235/355/212/270 /354/240/225/354/235/230/354/204/234.md" +50 -0
  85. lanternspec/resources/templates//355/224/214/353/236/253/355/217/274 /354/227/260/353/217/231.md" +45 -0
  86. lanternspec/resources/templates//355/225/240 /354/235/274.md" +33 -0
  87. lanternspec/resources/templates//355/230/221/354/227/205 /352/267/234/354/271/231.md" +35 -0
  88. lanternspec/resources/templates//355/231/224/353/251/264.md +36 -0
  89. lanternspec/resources/templates//355/232/214/354/235/230/353/241/235.md +33 -0
  90. lanternspec/tools/__init__.py +37 -0
  91. lanternspec/tools/agent_hooks.py +293 -0
  92. lanternspec/tools/antigravity.py +124 -0
  93. lanternspec/tools/base.py +149 -0
  94. lanternspec/tools/claude.py +132 -0
  95. lanternspec/tools/close.py +43 -0
  96. lanternspec/tools/codex.py +73 -0
  97. lanternspec/tools/docs_mode.py +200 -0
  98. lanternspec/tools/install.py +232 -0
  99. lanternspec/tools/rules.py +65 -0
  100. lanternspec/tools/runlog.py +107 -0
  101. lanternspec/tools/skills.py +160 -0
  102. lanternspec/work/__init__.py +6 -0
  103. lanternspec/work/branches.py +299 -0
  104. lanternspec/work/dbml.py +629 -0
  105. lanternspec/work/epic_rules.py +173 -0
  106. lanternspec/work/ideas.py +108 -0
  107. lanternspec/work/pack.py +102 -0
  108. lanternspec/work/progress.py +195 -0
  109. lanternspec/work/scaffold.py +373 -0
  110. lanternspec/work/screen.py +165 -0
  111. lanternspec/work/stages.py +62 -0
  112. lanternspec/work/tasks.py +284 -0
  113. lanternspec-0.1.0.dist-info/METADATA +260 -0
  114. lanternspec-0.1.0.dist-info/RECORD +118 -0
  115. lanternspec-0.1.0.dist-info/WHEEL +5 -0
  116. lanternspec-0.1.0.dist-info/entry_points.txt +2 -0
  117. lanternspec-0.1.0.dist-info/licenses/LICENSE +21 -0
  118. lanternspec-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,284 @@
1
+ """git hooks — 구조 실수는 막고(block), 내용 실수는 경고(warn) (C-09 · C-10 · I-03).
2
+
3
+ commit-msg H-1 작업 ID가 있나 · 할 일 여러 개를 섞었나
4
+ pre-commit 구조 docs/.git · .gitignore · .ignore · 코드 커밋에 docs/ 섞임 (항상 막음)
5
+ H-2 스키마 변경에 기록이 있나
6
+ H-3 바뀐 문서에 lantern check
7
+ pre-push R6 docs-local 브랜치 push 막기 (허용한 개인 원격만 통과)
8
+
9
+ Shell shims call `lantern hook git-…`; an existing non-LanternSpec hook is kept as `<name>.local`
10
+ and still runs first.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import re
17
+ import stat
18
+ import sys
19
+ from pathlib import Path
20
+
21
+ from ..core.project import Project, git_out, nfc
22
+
23
+ MARK = "# lanternspec hook"
24
+ HOOKS = {"commit-msg": "git-commit-msg", "pre-commit": "git-pre-commit", "pre-push": "git-pre-push"}
25
+ DOCS_BRANCH = "docs-local"
26
+ ID_RE = re.compile(r"\b(?:IDEA|W|ADR|D)-\d{3,}(?:-T\d{2,})?\b")
27
+ TASK_RE = re.compile(r"\bW-\d{3,}-T\d{2,}\b")
28
+ SKIP_MSG = re.compile(r"^(Merge |Revert |fixup! |squash! |amend! )|\[lantern\]")
29
+
30
+ SHIM = """#!/bin/sh
31
+ {mark} — `lantern hooks uninstall`로 지운다. 고치지 말 것.
32
+ HOOK_DIR=$(cd "$(dirname "$0")" && pwd)
33
+ {prelude}if [ -x "$HOOK_DIR/{name}.local" ]; then
34
+ {local_call} || exit $?
35
+ fi
36
+ if command -v lantern >/dev/null 2>&1; then
37
+ {feed}lantern hook {handler} "$@"
38
+ exit $?
39
+ elif python3 -c "import lanternspec" >/dev/null 2>&1; then
40
+ {feed}python3 -m lanternspec hook {handler} "$@"
41
+ exit $?
42
+ fi
43
+ echo "lanternspec: lantern 명령을 찾지 못해 검사를 건너뜁니다" >&2
44
+ exit 0
45
+ """
46
+
47
+
48
+ def hooks_dir(project: Project) -> Path:
49
+ custom = git_out(["config", "--get", "core.hooksPath"], project.root)
50
+ if custom:
51
+ p = Path(custom)
52
+ return p if p.is_absolute() else project.root / p
53
+ common = git_out(["rev-parse", "--path-format=absolute", "--git-common-dir"], project.root)
54
+ if not common:
55
+ raise RuntimeError("git 저장소가 아닙니다")
56
+ return Path(common) / "hooks"
57
+
58
+
59
+ def _shim(name: str) -> str:
60
+ if name == "pre-push": # stdin must reach both the local hook and ours
61
+ return SHIM.format(
62
+ mark=MARK,
63
+ name=name,
64
+ handler=HOOKS[name],
65
+ prelude="INPUT=$(cat)\n",
66
+ local_call=f'printf "%s\\n" "$INPUT" | "$HOOK_DIR/{name}.local" "$@"',
67
+ feed='printf "%s\\n" "$INPUT" | ',
68
+ )
69
+ return SHIM.format(mark=MARK, name=name, handler=HOOKS[name], prelude="", local_call=f'"$HOOK_DIR/{name}.local" "$@"', feed="")
70
+
71
+
72
+ def install(project: Project) -> list[str]:
73
+ d = hooks_dir(project)
74
+ d.mkdir(parents=True, exist_ok=True)
75
+ msgs = []
76
+ for name in HOOKS:
77
+ p = d / name
78
+ if p.exists() and MARK not in p.read_text(encoding="utf-8", errors="replace"):
79
+ local = d / f"{name}.local"
80
+ if not local.exists():
81
+ p.rename(local)
82
+ msgs.append(f"• 기존 {name} hook을 {name}.local로 보존 (먼저 실행됨)")
83
+ p.write_text(_shim(name), encoding="utf-8")
84
+ p.chmod(p.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
85
+ msgs.append(f"✔ git hook 설치: {', '.join(HOOKS)} ({d})")
86
+ return msgs
87
+
88
+
89
+ def uninstall(project: Project) -> list[str]:
90
+ d = hooks_dir(project)
91
+ msgs = []
92
+ for name in HOOKS:
93
+ p = d / name
94
+ if p.exists() and MARK in p.read_text(encoding="utf-8", errors="replace"):
95
+ p.unlink()
96
+ local = d / f"{name}.local"
97
+ if local.exists():
98
+ local.rename(p)
99
+ msgs.append(f"• {name}.local을 원래 이름으로 되돌림")
100
+ msgs.append(f"✔ {name} 제거")
101
+ return msgs or ["설치된 LanternSpec hook이 없습니다"]
102
+
103
+
104
+ def status(project: Project) -> list[tuple[str, bool]]:
105
+ try:
106
+ d = hooks_dir(project)
107
+ except RuntimeError:
108
+ return [(n, False) for n in HOOKS]
109
+ return [(n, (d / n).exists() and MARK in (d / n).read_text(encoding="utf-8", errors="replace")) for n in HOOKS]
110
+
111
+
112
+ # --------------------------------------------------------------------------- handlers
113
+
114
+
115
+ def _skip() -> bool:
116
+ return os.environ.get("LANTERN_SKIP_HOOKS") == "1"
117
+
118
+
119
+ def _mode(project: Project) -> str:
120
+ return str(project.profile.get("git.hooks") or "warn")
121
+
122
+
123
+ def _say(lines: list[str]) -> None:
124
+ for line in lines:
125
+ print(line, file=sys.stderr)
126
+
127
+
128
+ def _toplevel() -> Path:
129
+ return Path(git_out(["rev-parse", "--show-toplevel"], Path.cwd()) or Path.cwd())
130
+
131
+
132
+ def _branch(cwd: Path) -> str:
133
+ b = git_out(["symbolic-ref", "--short", "-q", "HEAD"], cwd)
134
+ return b or git_out(["rev-parse", "--abbrev-ref", "HEAD"], cwd)
135
+
136
+
137
+ def _staged(cwd: Path) -> list[str]:
138
+ out = git_out(["diff", "--cached", "--name-only", "--diff-filter=ACMRD"], cwd)
139
+ return [nfc(x) for x in out.splitlines() if x.strip()]
140
+
141
+
142
+ def _content_result(project: Project, problems: list[str], label: str) -> int:
143
+ if not problems:
144
+ return 0
145
+ block = _mode(project) == "block"
146
+ _say([f"{'✖' if block else '⚠'} LanternSpec {label}"] + [f" {p}" for p in problems])
147
+ if block:
148
+ _say([" (git.hooks: block — 고친 뒤 다시 커밋. 급하면 git commit --no-verify)"])
149
+ return 1
150
+ _say([" (경고만 — 커밋은 진행됩니다. 막으려면 lantern.yaml git.hooks: block)"])
151
+ return 0
152
+
153
+
154
+ def commit_msg(project: Project, msg_file: str) -> int:
155
+ if _skip():
156
+ return 0
157
+ text = Path(msg_file).read_text(encoding="utf-8", errors="replace")
158
+ msg = "\n".join(l for l in text.split("\n") if not l.startswith("#")).strip()
159
+ if not msg or SKIP_MSG.search(msg):
160
+ return 0
161
+ top = _toplevel()
162
+ branch = _branch(top)
163
+ docs_branch = branch == DOCS_BRANCH
164
+ staged = _staged(top)
165
+ from ..report.scan import is_doc_path
166
+
167
+ code = staged if docs_branch else [f for f in staged if not is_doc_path(project, f)]
168
+ problems: list[str] = []
169
+ if code and not ID_RE.search(msg):
170
+ where = "문서 브랜치" if docs_branch else "코드 커밋"
171
+ problems.append(f"H-1 {where} 메시지에 작업 ID가 없습니다 — 끝에 [W-012-T03] 처럼 붙이세요")
172
+ tasks = sorted(set(TASK_RE.findall(msg)))
173
+ if len(tasks) > 1:
174
+ problems.append(f"H-1 할 일 여러 개가 한 커밋에 섞였습니다 ({', '.join(tasks)}) — 할 일 하나 = 커밋 하나 (BU-01)")
175
+ return _content_result(project, problems, "커밋 메시지 검사")
176
+
177
+
178
+ def structure_problems(project: Project, cwd: Path | None = None, staged: list[str] | None = None) -> list[str]:
179
+ """C-10 rules. Always blocking. Also used by `lantern doctor`."""
180
+ cwd = cwd or project.root
181
+ branch = _branch(cwd)
182
+ docs = project.docs
183
+ pattern = project.profile.docs_root.rstrip("/") + "/"
184
+ gi = project.root / ".gitignore"
185
+ ig = project.root / ".ignore"
186
+ gi_has = gi.exists() and any(
187
+ l.strip() in (pattern, "/" + pattern, pattern.rstrip("/")) for l in gi.read_text(encoding="utf-8").split("\n")
188
+ )
189
+ ig_has = ig.exists() and any(l.strip() in ("!" + pattern, "!/" + pattern) for l in ig.read_text(encoding="utf-8").split("\n"))
190
+ out: list[str] = []
191
+ if project.profile.remote == "shared":
192
+ if (docs / ".git").exists():
193
+ out.append("docs/ 안에 .git이 있습니다 (shared 방식에서는 없어야 함) — lantern doctor")
194
+ if gi_has:
195
+ out.append(".gitignore가 docs/를 무시합니다 (shared 방식이면 지워야 함)")
196
+ else:
197
+ g = docs / ".git"
198
+ if g.is_dir():
199
+ out.append("docs/.git이 폴더입니다 — docs-local 브랜치를 가리키는 표지판 파일이어야 합니다 (lantern docs-mode)")
200
+ elif not g.exists():
201
+ out.append("docs/가 docs-local worktree가 아닙니다 — lantern docs-mode local 로 만드세요")
202
+ if not gi_has:
203
+ out.append(".gitignore에 docs/가 없습니다 (R1)")
204
+ if not ig_has:
205
+ out.append(".ignore에 !docs/가 없습니다 — AI 검색이 문서를 못 찾습니다 (R2)")
206
+ if branch != DOCS_BRANCH and staged is not None:
207
+ mixed = [f for f in staged if f.startswith(pattern)]
208
+ if mixed:
209
+ out.append(f"코드 커밋에 docs/ 파일이 섞였습니다: {', '.join(mixed[:3])} — 문서는 docs-local에 따로 커밋")
210
+ return out
211
+
212
+
213
+ def pre_commit(project: Project) -> int:
214
+ if _skip():
215
+ return 0
216
+ top = _toplevel()
217
+ branch = _branch(top)
218
+ staged = _staged(top)
219
+ docs_branch = branch == DOCS_BRANCH
220
+ structural = structure_problems(project, cwd=top, staged=staged)
221
+ if structural:
222
+ _say(["✖ LanternSpec 구조 검사 — 커밋을 막았습니다 (구조 실수는 항상 막음)"] + [f" {p}" for p in structural])
223
+ return 1
224
+ problems: list[str] = []
225
+ # H-2 schema change without a record
226
+ from ..report.scan import is_doc_path, schema_files
227
+
228
+ if not docs_branch:
229
+ sfiles = schema_files(project, staged)
230
+ if sfiles:
231
+ if project.profile.remote == "shared":
232
+ recorded = any(nfc(f).endswith("기록.md") for f in staged)
233
+ else:
234
+ docs_status = git_out(["status", "--porcelain"], project.docs) if project.docs.exists() else ""
235
+ recent = git_out(["log", "--since=1.day", "--name-only", "--format="], project.docs) if project.docs.exists() else ""
236
+ recorded = "기록.md" in nfc(docs_status) or "기록.md" in nfc(recent)
237
+ if not recorded:
238
+ problems.append(
239
+ f"H-2 스키마 파일이 바뀌었는데 기록.md 변경이 없습니다: {', '.join(sfiles[:3])}"
240
+ " — 작업 기록.md '## 스키마 변경'에 남기세요"
241
+ )
242
+ # H-3 lantern check on the documents in this commit
243
+ from . import check_docs
244
+
245
+ if docs_branch:
246
+ doc_paths = [top / f for f in staged if f.endswith(".md")]
247
+ else:
248
+ doc_paths = [
249
+ project.root / f
250
+ for f in staged
251
+ if f.endswith(".md") and is_doc_path(project, f) and f.startswith(project.profile.docs_root.rstrip("/") + "/")
252
+ ]
253
+ doc_paths = [p for p in doc_paths if p.exists()]
254
+ if doc_paths:
255
+ rep = check_docs.run(project, doc_paths)
256
+ bad = [f for f in rep.findings if f.level == "error"]
257
+ warn = [f for f in rep.findings if f.level == "warn"]
258
+ for f in bad:
259
+ problems.append(f"H-3 {f.path}: {f.message}")
260
+ if warn and not bad:
261
+ _say([f"⚠ LanternSpec H-3 참고: {f.path}: {f.message}" for f in warn[:5]])
262
+ return _content_result(project, problems, "커밋 전 검사")
263
+
264
+
265
+ def pre_push(project: Project, remote: str, url: str, stdin_text: str) -> int:
266
+ if _skip():
267
+ return 0
268
+ allowed = git_out(["config", "--get-all", "lantern.allowDocsRemote"], project.root).split()
269
+ for line in stdin_text.splitlines():
270
+ parts = line.split()
271
+ if len(parts) < 4:
272
+ continue
273
+ local_ref, remote_ref = parts[0], parts[2]
274
+ if f"refs/heads/{DOCS_BRANCH}" in (local_ref, remote_ref):
275
+ if remote in allowed:
276
+ return 0
277
+ _say(
278
+ [
279
+ f"✖ LanternSpec: {DOCS_BRANCH} 브랜치는 원격({remote})에 올리지 않습니다 (docs.remote: local)",
280
+ f" 개인 원격을 허용하려면: git config --add lantern.allowDocsRemote {remote}",
281
+ ]
282
+ )
283
+ return 1
284
+ return 0
@@ -0,0 +1,116 @@
1
+ """브라운필드 기존 문서 (I-02): 원본은 `5. 기존 문서/`에 그대로, 지도와 TODO 후보는 스크립트가."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+
9
+ from ..core.profile import FOLDERS, MOC_FILE
10
+ from ..core.project import Project, git, git_out, nfc, read, write
11
+
12
+ TODO_RE = re.compile(r"(TODO|FIXME|나중에|추후|언젠가|해야 할|할 것|아이디어|개선 필요|- \[ \])", re.I)
13
+
14
+
15
+ def _ours(project: Project) -> set:
16
+ tops = {nfc(v.split("/")[0]) for v in FOLDERS.values()}
17
+ return tops | {nfc(MOC_FILE), ".git", ".obsidian"}
18
+
19
+
20
+ def _move(project: Project, src: Path, dest: Path) -> None:
21
+ dest.parent.mkdir(parents=True, exist_ok=True)
22
+ tracked = bool(git_out(["ls-files", "--", str(src)], project.root))
23
+ if tracked:
24
+ r = git(["mv", str(src), str(dest)], project.root)
25
+ if r.returncode == 0:
26
+ return
27
+ shutil.move(str(src), str(dest))
28
+
29
+
30
+ def adopt(project: Project, source: Path | None = None) -> list[str]:
31
+ """Move existing documents into docs/5. 기존 문서/ (git mv keeps their history)."""
32
+ legacy = project.folder("legacy")
33
+ moved: list[str] = []
34
+ if source is None:
35
+ if not project.docs.exists():
36
+ return []
37
+ ours = _ours(project)
38
+ for item in sorted(project.docs.iterdir()):
39
+ if nfc(item.name) in ours or item.name.startswith("."):
40
+ continue
41
+ dest = legacy / item.name
42
+ _move(project, item, dest)
43
+ moved.append(project.rel(dest))
44
+ else:
45
+ src = source if source.is_absolute() else project.root / source
46
+ if not src.exists():
47
+ raise FileNotFoundError(f"없습니다: {source}")
48
+ dest = legacy / src.name
49
+ _move(project, src, dest)
50
+ moved.append(project.rel(dest))
51
+ legacy.mkdir(parents=True, exist_ok=True)
52
+ add_map_rows(project)
53
+ return moved
54
+
55
+
56
+ def legacy_files(project: Project) -> list[Path]:
57
+ legacy = project.folder("legacy")
58
+ if not legacy.exists():
59
+ return []
60
+ return sorted(
61
+ p
62
+ for p in legacy.rglob("*")
63
+ if p.is_file() and nfc(p.name) != "기존 문서 지도.md" and not any(x.startswith(".") for x in p.relative_to(legacy).parts)
64
+ )
65
+
66
+
67
+ def map_rows(project: Project, files: list[Path]) -> str:
68
+ rows = []
69
+ for p in files:
70
+ rel = project.docs_rel(p)
71
+ target = rel[:-3] if rel.endswith(".md") else rel
72
+ rows.append(
73
+ f"| [[{target}\\|{nfc(p.stem)}]] | {{{{무슨 내용}}}} | {{{{아직 맞나}}}} | {{{{흡수 · 보관 · 옮기기}}}} | {{{{새 위치}}}} |"
74
+ )
75
+ return "\n".join(rows) or "| {{문서}} | {{무슨 내용}} | {{아직 맞나}} | {{흡수 · 보관 · 옮기기}} | {{새 위치}} |"
76
+
77
+
78
+ def add_map_rows(project: Project) -> Path | None:
79
+ """Add a row for every legacy file the map does not mention yet."""
80
+ m = project.folder("legacy") / "기존 문서 지도.md"
81
+ if not m.exists():
82
+ return None
83
+ text = read(m)
84
+ missing = [p for p in legacy_files(project) if project.docs_rel(p).rsplit(".", 1)[0] not in text]
85
+ if not missing:
86
+ return m
87
+ rows = map_rows(project, missing)
88
+ lines = text.split("\n")
89
+ # drop the template's blank example row once real rows exist
90
+ lines = [l for l in lines if l.strip() != "| {{문서}} | {{무슨 내용}} | {{아직 맞나}} | {{흡수 · 보관 · 옮기기}} | {{새 위치}} |"]
91
+ try:
92
+ start = next(i for i, l in enumerate(lines) if l.strip() == "## 문서")
93
+ except StopIteration:
94
+ write(m, text.rstrip() + "\n\n## 문서\n| 문서 | 무슨 내용 | 아직 맞나 | 갈래 | 새 위치 |\n|---|---|---|---|---|\n" + rows + "\n")
95
+ return m
96
+ last = start
97
+ for i in range(start + 1, len(lines)):
98
+ if lines[i].startswith("|"):
99
+ last = i
100
+ elif lines[i].startswith("## "):
101
+ break
102
+ lines[last + 1 : last + 1] = rows.split("\n")
103
+ write(m, "\n".join(lines))
104
+ return m
105
+
106
+
107
+ def todos(project: Project) -> list[dict[str, str]]:
108
+ out = []
109
+ for p in legacy_files(project):
110
+ if p.suffix.lower() not in (".md", ".txt", ".markdown"):
111
+ continue
112
+ for i, line in enumerate(read(p).split("\n"), start=1):
113
+ s = line.strip()
114
+ if s and TODO_RE.search(s) and not s.startswith("- [x]"):
115
+ out.append({"file": project.docs_rel(p), "line": str(i), "text": s[:160]})
116
+ return out