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.
- lanternspec/__init__.py +8 -0
- lanternspec/__main__.py +3 -0
- lanternspec/check/__init__.py +4 -0
- lanternspec/check/check_docs.py +468 -0
- lanternspec/check/doctor.py +113 -0
- lanternspec/check/githooks.py +284 -0
- lanternspec/check/legacy.py +116 -0
- lanternspec/check/templates.py +505 -0
- lanternspec/cli/__init__.py +53 -0
- lanternspec/cli/check.py +184 -0
- lanternspec/cli/common.py +31 -0
- lanternspec/cli/core.py +252 -0
- lanternspec/cli/learn.py +164 -0
- lanternspec/cli/report.py +124 -0
- lanternspec/cli/tools.py +116 -0
- lanternspec/cli/work.py +441 -0
- lanternspec/core/__init__.py +5 -0
- lanternspec/core/enums.py +457 -0
- lanternspec/core/frontmatter.py +150 -0
- lanternspec/core/links.py +100 -0
- lanternspec/core/meta.py +82 -0
- lanternspec/core/new_id.py +74 -0
- lanternspec/core/principles.py +457 -0
- lanternspec/core/profile.py +524 -0
- lanternspec/core/project.py +226 -0
- lanternspec/core/state.py +51 -0
- lanternspec/core/taskfile.py +163 -0
- lanternspec/core/templating.py +133 -0
- lanternspec/learn/__init__.py +4 -0
- lanternspec/learn/debt.py +411 -0
- lanternspec/learn/knowledge/__init__.py +67 -0
- lanternspec/learn/knowledge/drafts.py +75 -0
- lanternspec/learn/knowledge/mcp_server.py +123 -0
- lanternspec/learn/knowledge/notion.py +157 -0
- lanternspec/learn/knowledge/obsidian.py +118 -0
- lanternspec/report/__init__.py +6 -0
- lanternspec/report/doc_map.py +181 -0
- lanternspec/report/evaluate.py +174 -0
- lanternspec/report/harness.py +127 -0
- lanternspec/report/principles_doc.py +134 -0
- lanternspec/report/scan.py +299 -0
- lanternspec/report/status.py +75 -0
- lanternspec/report/views.py +352 -0
- lanternspec/resources/__init__.py +23 -0
- lanternspec/resources/agents/AGENTS.md +55 -0
- lanternspec/resources/principles/conventions/go.yaml +53 -0
- lanternspec/resources/principles/conventions/python.yaml +82 -0
- lanternspec/resources/principles/conventions/rust.yaml +60 -0
- lanternspec/resources/principles/conventions/swift.yaml +59 -0
- lanternspec/resources/principles/conventions/typescript.yaml +83 -0
- lanternspec/resources/principles/principles.yaml +189 -0
- lanternspec/resources/skills/l-adr/SKILL.md +30 -0
- lanternspec/resources/skills/l-build/SKILL.md +100 -0
- lanternspec/resources/skills/l-check/SKILL.md +30 -0
- lanternspec/resources/skills/l-design/SKILL.md +78 -0
- lanternspec/resources/skills/l-idea/SKILL.md +52 -0
- lanternspec/resources/skills/l-init/SKILL.md +116 -0
- lanternspec/resources/skills/l-learn/SKILL.md +87 -0
- lanternspec/resources/skills/l-map/SKILL.md +20 -0
- lanternspec/resources/skills/l-plan/SKILL.md +79 -0
- lanternspec/resources/skills/l-report/SKILL.md +44 -0
- lanternspec/resources/skills/l-sync/SKILL.md +42 -0
- lanternspec/resources/skills/l-task/SKILL.md +46 -0
- lanternspec/resources/skills/l-template/SKILL.md +61 -0
- lanternspec/resources/templates/ADR.md +45 -0
- lanternspec/resources/templates/API.md +34 -0
- lanternspec/resources/templates/_meta/enums.yaml +188 -0
- lanternspec/resources/templates//352/260/234/353/260/234/353/254/270/354/204/234 MOC.md" +26 -0
- lanternspec/resources/templates//352/270/260/353/212/245 /353/252/251/353/241/235.md" +30 -0
- lanternspec/resources/templates//352/270/260/353/241/235.md +45 -0
- lanternspec/resources/templates//352/270/260/354/241/264 /353/254/270/354/204/234 /354/247/200/353/217/204.md" +31 -0
- lanternspec/resources/templates//352/270/260/355/232/215.md +59 -0
- lanternspec/resources/templates//353/215/260/354/235/264/355/204/260.md +38 -0
- lanternspec/resources/templates//353/260/260/355/217/254.md +45 -0
- lanternspec/resources/templates//353/266/200/354/261/204 /354/271/264/353/223/234.md" +46 -0
- 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
- lanternspec/resources/templates//354/204/244/352/263/204.md +61 -0
- lanternspec/resources/templates//354/204/270/354/205/230 /352/270/260/353/241/235.md" +27 -0
- lanternspec/resources/templates//354/213/234/354/212/244/355/205/234 /352/265/254/354/241/260.md" +41 -0
- lanternspec/resources/templates//354/225/204/353/212/224 /352/270/260/354/210/240.md" +25 -0
- lanternspec/resources/templates//354/225/204/354/235/264/353/224/224/354/226/264.md +44 -0
- lanternspec/resources/templates//354/227/255/353/237/211 /354/271/264/353/223/234.md" +42 -0
- lanternspec/resources/templates//354/236/221/354/227/205 /353/213/250/354/234/204 /352/270/260/353/241/235.md" +27 -0
- 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
- lanternspec/resources/templates//355/224/214/353/236/253/355/217/274 /354/227/260/353/217/231.md" +45 -0
- lanternspec/resources/templates//355/225/240 /354/235/274.md" +33 -0
- lanternspec/resources/templates//355/230/221/354/227/205 /352/267/234/354/271/231.md" +35 -0
- lanternspec/resources/templates//355/231/224/353/251/264.md +36 -0
- lanternspec/resources/templates//355/232/214/354/235/230/353/241/235.md +33 -0
- lanternspec/tools/__init__.py +37 -0
- lanternspec/tools/agent_hooks.py +293 -0
- lanternspec/tools/antigravity.py +124 -0
- lanternspec/tools/base.py +149 -0
- lanternspec/tools/claude.py +132 -0
- lanternspec/tools/close.py +43 -0
- lanternspec/tools/codex.py +73 -0
- lanternspec/tools/docs_mode.py +200 -0
- lanternspec/tools/install.py +232 -0
- lanternspec/tools/rules.py +65 -0
- lanternspec/tools/runlog.py +107 -0
- lanternspec/tools/skills.py +160 -0
- lanternspec/work/__init__.py +6 -0
- lanternspec/work/branches.py +299 -0
- lanternspec/work/dbml.py +629 -0
- lanternspec/work/epic_rules.py +173 -0
- lanternspec/work/ideas.py +108 -0
- lanternspec/work/pack.py +102 -0
- lanternspec/work/progress.py +195 -0
- lanternspec/work/scaffold.py +373 -0
- lanternspec/work/screen.py +165 -0
- lanternspec/work/stages.py +62 -0
- lanternspec/work/tasks.py +284 -0
- lanternspec-0.1.0.dist-info/METADATA +260 -0
- lanternspec-0.1.0.dist-info/RECORD +118 -0
- lanternspec-0.1.0.dist-info/WHEEL +5 -0
- lanternspec-0.1.0.dist-info/entry_points.txt +2 -0
- lanternspec-0.1.0.dist-info/licenses/LICENSE +21 -0
- 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
|