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
lanternspec/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""LanternSpec — 모르는 것은 밝히고, 걸어온 길은 보여 준다."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
__version__ = version("lanternspec") # 버전의 유일한 출처는 pyproject.toml — 설치할 때 옮겨 적힌 값을 읽는다
|
|
7
|
+
except PackageNotFoundError: # 설치하지 않고 소스 폴더를 바로 불러 쓸 때
|
|
8
|
+
__version__ = "0+unknown"
|
lanternspec/__main__.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""`lantern check` — 양식 검사 (/l-check, H-3, H-5).
|
|
2
|
+
|
|
3
|
+
Looks at: required blanks · enums.yaml values · broken links · leftover {{blanks}} ·
|
|
4
|
+
old template versions · duplicate IDs · document length · task line format.
|
|
5
|
+
The AI only explains the result; the verdict comes from here.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from collections import Counter, defaultdict
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from dataclasses import asdict, dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from ..core import enums as enums_mod
|
|
18
|
+
from ..core import frontmatter, templating
|
|
19
|
+
from ..core.links import Resolver, iter_links
|
|
20
|
+
from ..core.project import Project, git_out, nfc, read
|
|
21
|
+
from ..core.taskfile import parse_criteria, parse_tasks
|
|
22
|
+
|
|
23
|
+
COMMON_FIELDS = ["id", "type", "status", "created", "updated", "links", "template", "template_version"]
|
|
24
|
+
LEVEL_ICON = {"error": "✖", "warn": "⚠", "info": "ℹ"}
|
|
25
|
+
LEVEL_ORDER = {"error": 0, "warn": 1, "info": 2}
|
|
26
|
+
UNKNOWN = "❓ 확인 필요"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class Finding:
|
|
31
|
+
level: str
|
|
32
|
+
path: str
|
|
33
|
+
code: str
|
|
34
|
+
message: str
|
|
35
|
+
line: int | None = None
|
|
36
|
+
hint: str | None = None
|
|
37
|
+
|
|
38
|
+
def format(self) -> str:
|
|
39
|
+
loc = f":{self.line}" if self.line else ""
|
|
40
|
+
s = f" {LEVEL_ICON[self.level]} {self.message}{f' (줄 {self.line})' if loc else ''}"
|
|
41
|
+
if self.hint:
|
|
42
|
+
s += f"\n → {self.hint}"
|
|
43
|
+
return s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class Report:
|
|
48
|
+
findings: list[Finding]
|
|
49
|
+
files: int
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def errors(self) -> list[Finding]:
|
|
53
|
+
return [f for f in self.findings if f.level == "error"]
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def warnings(self) -> list[Finding]:
|
|
57
|
+
return [f for f in self.findings if f.level == "warn"]
|
|
58
|
+
|
|
59
|
+
def counts(self) -> dict[str, int]:
|
|
60
|
+
c = Counter(f.level for f in self.findings)
|
|
61
|
+
return {"error": c.get("error", 0), "warn": c.get("warn", 0), "info": c.get("info", 0)}
|
|
62
|
+
|
|
63
|
+
def format(self, show_info: bool = True) -> str:
|
|
64
|
+
lines: list[str] = []
|
|
65
|
+
by_file: dict[str, list[Finding]] = defaultdict(list)
|
|
66
|
+
for f in self.findings:
|
|
67
|
+
if f.level == "info" and not show_info:
|
|
68
|
+
continue
|
|
69
|
+
by_file[f.path].append(f)
|
|
70
|
+
for path in sorted(by_file):
|
|
71
|
+
lines.append(f"📄 {path}")
|
|
72
|
+
for f in sorted(by_file[path], key=lambda x: (LEVEL_ORDER[x.level], x.line or 0)):
|
|
73
|
+
lines.append(f.format())
|
|
74
|
+
c = self.counts()
|
|
75
|
+
if not c["error"] and not c["warn"]:
|
|
76
|
+
lines.append(f"✔ 양식에 맞음 — 문서 {self.files}개" + (f" (참고 {c['info']}개)" if c["info"] else ""))
|
|
77
|
+
else:
|
|
78
|
+
parts = []
|
|
79
|
+
if c["error"]:
|
|
80
|
+
parts.append(f"✖ 오류 {c['error']}개")
|
|
81
|
+
if c["warn"]:
|
|
82
|
+
parts.append(f"⚠ 경고 {c['warn']}개")
|
|
83
|
+
if c["info"]:
|
|
84
|
+
parts.append(f"ℹ 참고 {c['info']}개")
|
|
85
|
+
lines.append(f"문서 {self.files}개 검사 — " + " · ".join(parts))
|
|
86
|
+
return "\n".join(lines)
|
|
87
|
+
|
|
88
|
+
def to_json(self) -> str:
|
|
89
|
+
return json.dumps(
|
|
90
|
+
{"files": self.files, "counts": self.counts(), "findings": [asdict(f) for f in self.findings]}, ensure_ascii=False, indent=2
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def semver(v: object) -> tuple[int, int, int]:
|
|
95
|
+
m = re.match(r"^\s*(\d+)(?:\.(\d+))?(?:\.(\d+))?", str(v or ""))
|
|
96
|
+
if not m:
|
|
97
|
+
return (0, 0, 0)
|
|
98
|
+
return tuple(int(x or 0) for x in m.groups()) # type: ignore[return-value]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def version_gap(old: object, new: object) -> str | None:
|
|
102
|
+
"""'major' | 'minor' | 'patch' | None (same or newer)."""
|
|
103
|
+
a, b = semver(old), semver(new)
|
|
104
|
+
if a >= b:
|
|
105
|
+
return None
|
|
106
|
+
if a[0] != b[0]:
|
|
107
|
+
return "major"
|
|
108
|
+
if a[1] != b[1]:
|
|
109
|
+
return "minor"
|
|
110
|
+
return "patch"
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# --------------------------------------------------------------------------- helpers
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _section_spans(text: str) -> list[tuple[str, int, int]]:
|
|
117
|
+
"""(heading, start_line, end_line) for each `## ` section (1-based, inclusive)."""
|
|
118
|
+
lines = text.split("\n")
|
|
119
|
+
spans: list[tuple[str, int, int]] = []
|
|
120
|
+
current: tuple[str, int] | None = None
|
|
121
|
+
in_fence = False
|
|
122
|
+
for i, line in enumerate(lines, start=1):
|
|
123
|
+
if line.lstrip().startswith("```"):
|
|
124
|
+
in_fence = not in_fence
|
|
125
|
+
if not in_fence and line.startswith("## "):
|
|
126
|
+
if current:
|
|
127
|
+
spans.append((current[0], current[1], i - 1))
|
|
128
|
+
current = (line[3:].strip(), i)
|
|
129
|
+
if current:
|
|
130
|
+
spans.append((current[0], current[1], len(lines)))
|
|
131
|
+
return spans
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _glance_empty(body: str) -> bool:
|
|
135
|
+
lines = body.split("\n")
|
|
136
|
+
for i, line in enumerate(lines):
|
|
137
|
+
if re.match(r"^>\s*\[!abstract\][^\n]*한눈에 보기", line):
|
|
138
|
+
content = []
|
|
139
|
+
for nxt in lines[i + 1 :]:
|
|
140
|
+
if not nxt.startswith(">"):
|
|
141
|
+
break
|
|
142
|
+
content.append(nxt.lstrip(">").strip())
|
|
143
|
+
return not any(content)
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def is_generated(meta: dict) -> bool:
|
|
148
|
+
return bool(meta.get("generated"))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _active_doc(project: Project, path: Path, meta: dict) -> bool:
|
|
152
|
+
"""TE-03: only docs still in use get migrate suggestions."""
|
|
153
|
+
rel = project.docs_rel(path)
|
|
154
|
+
if rel.startswith(nfc("3. 현재 상태")):
|
|
155
|
+
return meta.get("status") != project.enums.value("current_status", "inactive")
|
|
156
|
+
if rel.startswith(nfc("2. 작업/보관")):
|
|
157
|
+
return False
|
|
158
|
+
if rel.startswith(nfc("2. 작업")):
|
|
159
|
+
return meta.get("status") != project.enums.value("work_status", "done")
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def required_headings(template_text: str) -> list[str]:
|
|
164
|
+
"""`## ` sections of a template that hold a (필수) blank — removing one breaks the structure."""
|
|
165
|
+
body = templating.strip_template_only(template_text)
|
|
166
|
+
out = []
|
|
167
|
+
for heading, start, end in _section_spans(body):
|
|
168
|
+
section = "\n".join(body.split("\n")[start - 1 : end])
|
|
169
|
+
if "(필수)}}" in section:
|
|
170
|
+
out.append(heading)
|
|
171
|
+
return out
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class TemplateIndex:
|
|
175
|
+
def __init__(self, project: Project) -> None:
|
|
176
|
+
self.versions: dict[str, str] = {}
|
|
177
|
+
self.required: dict[str, list[str]] = {}
|
|
178
|
+
texts: dict[str, str] = {}
|
|
179
|
+
if project.templates_dir.exists():
|
|
180
|
+
for p in project.templates_dir.glob("*.md"):
|
|
181
|
+
text = read(p)
|
|
182
|
+
meta = frontmatter.parse(text).meta
|
|
183
|
+
texts[nfc(str(meta.get("template") or p.stem))] = text
|
|
184
|
+
if not texts:
|
|
185
|
+
from ..work.scaffold import base_template_names, base_template_text
|
|
186
|
+
|
|
187
|
+
texts = {name: base_template_text(name) for name in base_template_names()}
|
|
188
|
+
for name, text in texts.items():
|
|
189
|
+
self.versions[name] = frontmatter.as_str(frontmatter.parse(text).meta.get("template_version"))
|
|
190
|
+
self.required[name] = required_headings(text)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# --------------------------------------------------------------------------- the check
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def check_file(project: Project, path: Path, text: str, ctx: _Ctx) -> list[Finding]:
|
|
197
|
+
rel = project.rel(path)
|
|
198
|
+
out: list[Finding] = []
|
|
199
|
+
doc = frontmatter.parse(text)
|
|
200
|
+
if doc.error:
|
|
201
|
+
return [Finding("error", rel, "frontmatter", doc.error, 1, "--- 사이의 YAML 문법을 고치세요")]
|
|
202
|
+
meta = doc.meta
|
|
203
|
+
if not doc.has_frontmatter:
|
|
204
|
+
out.append(
|
|
205
|
+
Finding(
|
|
206
|
+
"warn",
|
|
207
|
+
rel,
|
|
208
|
+
"no-frontmatter",
|
|
209
|
+
"frontmatter가 없습니다 (양식 밖 문서)",
|
|
210
|
+
hint="템플릿에서 만든 문서인지 확인하세요 (lantern scaffold …)",
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
generated = is_generated(meta)
|
|
214
|
+
disabled = meta.get("status") == project.enums.value("current_status", "inactive")
|
|
215
|
+
doc_type = str(meta.get("type") or "")
|
|
216
|
+
|
|
217
|
+
# common frontmatter
|
|
218
|
+
if doc.has_frontmatter and meta.get("template") and not generated:
|
|
219
|
+
missing = [k for k in COMMON_FIELDS if k not in meta]
|
|
220
|
+
if missing:
|
|
221
|
+
out.append(Finding("error", rel, "common-fields", f"공통 frontmatter 칸이 없습니다: {', '.join(missing)}", 1))
|
|
222
|
+
|
|
223
|
+
# enums
|
|
224
|
+
if doc_type and not generated:
|
|
225
|
+
for fieldname, enum in ctx.enums.rules_for(doc_type).items():
|
|
226
|
+
if fieldname not in meta:
|
|
227
|
+
continue
|
|
228
|
+
value = frontmatter.as_str(meta.get(fieldname))
|
|
229
|
+
if "{{" in value:
|
|
230
|
+
continue
|
|
231
|
+
if value and value not in enum.values:
|
|
232
|
+
out.append(
|
|
233
|
+
Finding(
|
|
234
|
+
"error",
|
|
235
|
+
rel,
|
|
236
|
+
"enum",
|
|
237
|
+
f"{fieldname}: '{value}'는 {enum.label} 값이 아닙니다",
|
|
238
|
+
1,
|
|
239
|
+
f"가능: {', '.join(enum.values)} (enums.yaml)",
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# placeholders
|
|
244
|
+
if not generated and not disabled:
|
|
245
|
+
optional_lines: list[int] = []
|
|
246
|
+
for name, required, line_no in templating.placeholders(text):
|
|
247
|
+
if required:
|
|
248
|
+
out.append(Finding("warn", rel, "required-blank", f"필수 칸이 비어 있습니다: {{{{{name}}}}}", line_no))
|
|
249
|
+
else:
|
|
250
|
+
optional_lines.append(line_no)
|
|
251
|
+
if optional_lines:
|
|
252
|
+
out.append(
|
|
253
|
+
Finding("info", rel, "blank", f"빈 칸 {len(optional_lines)}개", optional_lines[0], "채우거나, 해당 없으면 칸을 지우세요")
|
|
254
|
+
)
|
|
255
|
+
if _glance_empty(doc.body):
|
|
256
|
+
out.append(Finding("warn", rel, "glance-empty", "'한눈에 보기' 칸이 비어 있습니다"))
|
|
257
|
+
|
|
258
|
+
# unknowns
|
|
259
|
+
unknown_lines = [i for i, l in enumerate(text.split("\n"), start=1) if UNKNOWN in l and "`" + UNKNOWN not in l and "[!guide]" not in l]
|
|
260
|
+
if unknown_lines and not generated:
|
|
261
|
+
out.append(
|
|
262
|
+
Finding("info", rel, "unknown", f"{UNKNOWN} {len(unknown_lines)}개", unknown_lines[0], "답을 받으면 해당 칸에 반영하세요")
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
# links
|
|
266
|
+
for link in iter_links(text):
|
|
267
|
+
if ctx.resolver.resolve(link, path) is None:
|
|
268
|
+
out.append(Finding("error", rel, "broken-link", f"깨진 링크: {link.raw}", link.line_no, "대상 문서 이름 · 경로를 확인하세요"))
|
|
269
|
+
|
|
270
|
+
# template version (TE-03)
|
|
271
|
+
tname = nfc(str(meta.get("template") or ""))
|
|
272
|
+
if tname and tname in ctx.templates.versions and not generated:
|
|
273
|
+
gap = version_gap(meta.get("template_version"), ctx.templates.versions[tname])
|
|
274
|
+
if gap and _active_doc(project, path, meta):
|
|
275
|
+
if gap == "major":
|
|
276
|
+
out.append(
|
|
277
|
+
Finding(
|
|
278
|
+
"warn",
|
|
279
|
+
rel,
|
|
280
|
+
"template-major",
|
|
281
|
+
f"양식 '{tname}'이 {meta.get('template_version')} → {ctx.templates.versions[tname]}로 크게 바뀌었습니다",
|
|
282
|
+
1,
|
|
283
|
+
f'lantern template migrate "{project.docs_rel(path)}" 로 옮기세요 (/l-template migrate)',
|
|
284
|
+
)
|
|
285
|
+
)
|
|
286
|
+
elif gap == "minor":
|
|
287
|
+
out.append(
|
|
288
|
+
Finding(
|
|
289
|
+
"info",
|
|
290
|
+
rel,
|
|
291
|
+
"template-minor",
|
|
292
|
+
f"양식 '{tname}'에 새 칸이 생겼습니다 ({meta.get('template_version')} → {ctx.templates.versions[tname]})",
|
|
293
|
+
1,
|
|
294
|
+
)
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
# structure: sections holding required blanks must survive (H-5 "AI가 템플릿 구조를 바꿈")
|
|
298
|
+
if tname and tname in ctx.templates.required and not generated and not disabled:
|
|
299
|
+
have = {h for h, _, _ in _section_spans(text)}
|
|
300
|
+
for heading in ctx.templates.required[tname]:
|
|
301
|
+
if heading not in have:
|
|
302
|
+
out.append(
|
|
303
|
+
Finding(
|
|
304
|
+
"error",
|
|
305
|
+
rel,
|
|
306
|
+
"template-structure",
|
|
307
|
+
f"양식 '{tname}'의 필수 칸 '## {heading}'이 사라졌습니다",
|
|
308
|
+
None,
|
|
309
|
+
"칸을 지우지 말고 내용을 채우세요. 해당 없으면 '없음'",
|
|
310
|
+
)
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
# idea: dropped needs a reason (ID-02)
|
|
314
|
+
if (
|
|
315
|
+
doc_type == "idea"
|
|
316
|
+
and meta.get("status") == project.enums.value("idea_status", "dropped")
|
|
317
|
+
and not str(meta.get("dropped_reason") or "").strip()
|
|
318
|
+
):
|
|
319
|
+
out.append(Finding("warn", rel, "dropped-reason", "버린 아이디어에는 이유 한 줄이 필요합니다 (dropped_reason)", 1))
|
|
320
|
+
|
|
321
|
+
# tasks
|
|
322
|
+
if doc_type == "tasks":
|
|
323
|
+
out += _check_tasks(project, path, text, rel)
|
|
324
|
+
|
|
325
|
+
# length (CH-01)
|
|
326
|
+
max_lines = int(project.profile.get("check.max_lines") or 300)
|
|
327
|
+
n_lines = text.count("\n") + 1
|
|
328
|
+
if not generated and n_lines > max_lines:
|
|
329
|
+
spans = [(h, e - s + 1) for h, s, e in _section_spans(text) if h != "변경 기록"]
|
|
330
|
+
hint = "섹션을 별도 문서로 나누고 링크하세요"
|
|
331
|
+
if spans:
|
|
332
|
+
big = max(spans, key=lambda x: x[1])
|
|
333
|
+
hint = f"'## {big[0]}' 칸이 {big[1]}줄 → '{big[0]}.md'로 나누고 링크?"
|
|
334
|
+
out.append(Finding("warn", rel, "too-long", f"문서가 {n_lines}줄입니다 (기준 {max_lines}줄)", None, hint))
|
|
335
|
+
|
|
336
|
+
# remember ids
|
|
337
|
+
if meta.get("id") and doc_type:
|
|
338
|
+
ctx.ids[(doc_type, str(meta["id"]))].append(rel)
|
|
339
|
+
return out
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _check_tasks(project: Project, path: Path, text: str, rel: str) -> list[Finding]:
|
|
343
|
+
out: list[Finding] = []
|
|
344
|
+
tasks = parse_tasks(text)
|
|
345
|
+
ids = Counter(t.id for t in tasks)
|
|
346
|
+
known = set(ids)
|
|
347
|
+
plan = path.parent / "기획.md"
|
|
348
|
+
acs = {c.id for c in parse_criteria(read(plan))} if plan.exists() else set()
|
|
349
|
+
team = project.profile.team
|
|
350
|
+
for t in tasks:
|
|
351
|
+
line = t.line_no + 1
|
|
352
|
+
if ids[t.id] > 1:
|
|
353
|
+
out.append(Finding("error", rel, "task-dup", f"할 일 ID가 겹칩니다: {t.id}", line))
|
|
354
|
+
if t.size and t.size not in ("S", "M", "L") and "{{" not in t.size:
|
|
355
|
+
out.append(Finding("warn", rel, "task-size", f"{t.id} 크기는 S · M · L 중 하나입니다 (지금 '{t.size}')", line))
|
|
356
|
+
if not t.size:
|
|
357
|
+
out.append(Finding("warn", rel, "task-size", f"{t.id}에 크기가 없습니다 [크기:: S|M|L]", line))
|
|
358
|
+
for dep in t.deps:
|
|
359
|
+
if dep not in known:
|
|
360
|
+
out.append(Finding("warn", rel, "task-dep", f"{t.id}: 먼저 끝나야 할 일 {dep}이 목록에 없습니다", line))
|
|
361
|
+
elif dep == t.id:
|
|
362
|
+
out.append(Finding("warn", rel, "task-dep", f"{t.id}: 자기 자신을 먼저로 지정했습니다", line))
|
|
363
|
+
for ac in t.criteria:
|
|
364
|
+
if acs and ac not in acs:
|
|
365
|
+
out.append(Finding("warn", rel, "task-ac", f"{t.id}: 수락 기준 {ac}이 기획.md에 없습니다", line))
|
|
366
|
+
if team and not t.attrs.get("담당") and not t.done:
|
|
367
|
+
out.append(Finding("info", rel, "task-owner", f"{t.id}에 담당이 없습니다 [담당:: 이름]", line))
|
|
368
|
+
covered = {ac for t in tasks for ac in t.criteria}
|
|
369
|
+
real_tasks = [t for t in tasks if "{{" not in t.title]
|
|
370
|
+
if real_tasks:
|
|
371
|
+
for ac in sorted(acs - covered, key=lambda x: int(x.split("-")[1])):
|
|
372
|
+
out.append(
|
|
373
|
+
Finding("warn", rel, "ac-uncovered", f"수락 기준 {ac}에 연결된 할 일이 없습니다", None, f"lantern task add … --ac {ac}")
|
|
374
|
+
)
|
|
375
|
+
if len(tasks) > 10:
|
|
376
|
+
out.append(Finding("info", rel, "task-many", f"할 일이 {len(tasks)}개입니다 — 작업을 나누는 것을 검토하세요"))
|
|
377
|
+
# cycles
|
|
378
|
+
graph: dict[str, list[str]] = {}
|
|
379
|
+
for t in tasks: # a duplicated ID keeps every line's dependencies
|
|
380
|
+
graph.setdefault(t.id, []).extend(t.deps)
|
|
381
|
+
state: dict[str, int] = {}
|
|
382
|
+
|
|
383
|
+
def visit(n: str, stack: list[str]) -> list[str] | None:
|
|
384
|
+
state[n] = 1
|
|
385
|
+
for d in graph.get(n, []):
|
|
386
|
+
if d not in graph:
|
|
387
|
+
continue
|
|
388
|
+
if state.get(d) == 1:
|
|
389
|
+
return stack + [n, d]
|
|
390
|
+
if state.get(d) is None:
|
|
391
|
+
r = visit(d, stack + [n])
|
|
392
|
+
if r:
|
|
393
|
+
return r
|
|
394
|
+
state[n] = 2
|
|
395
|
+
return None
|
|
396
|
+
|
|
397
|
+
for n in graph:
|
|
398
|
+
if state.get(n) is None:
|
|
399
|
+
cyc = visit(n, [])
|
|
400
|
+
if cyc:
|
|
401
|
+
out.append(Finding("error", rel, "task-cycle", f"먼저 끝나야 할 일이 순환합니다: {' → '.join(cyc)}"))
|
|
402
|
+
break
|
|
403
|
+
return out
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
class _Ctx:
|
|
407
|
+
def __init__(self, project: Project) -> None:
|
|
408
|
+
self.enums = enums_mod.load(project.enums_file)
|
|
409
|
+
self.resolver = Resolver(project.docs)
|
|
410
|
+
self.templates = TemplateIndex(project)
|
|
411
|
+
self.ids: dict[tuple[str, str], list[str]] = defaultdict(list)
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def changed_docs(project: Project) -> list[Path]:
|
|
415
|
+
cwd = project.docs_git_cwd()
|
|
416
|
+
out = git_out(["status", "--porcelain", "--untracked-files=all", "--", "."], cwd)
|
|
417
|
+
paths: list[Path] = []
|
|
418
|
+
for line in out.splitlines():
|
|
419
|
+
p = line[3:].strip().strip('"')
|
|
420
|
+
if " -> " in p:
|
|
421
|
+
p = p.split(" -> ", 1)[1]
|
|
422
|
+
full = cwd / p
|
|
423
|
+
if full.suffix == ".md" and full.exists():
|
|
424
|
+
try:
|
|
425
|
+
full.resolve().relative_to(project.docs.resolve())
|
|
426
|
+
except ValueError:
|
|
427
|
+
continue
|
|
428
|
+
paths.append(full)
|
|
429
|
+
return paths
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def run(project: Project, paths: Iterable[Path] | None = None) -> Report:
|
|
433
|
+
ctx = _Ctx(project)
|
|
434
|
+
all_docs = list(project.iter_docs())
|
|
435
|
+
targets = [Path(p).resolve() for p in paths] if paths is not None else [p.resolve() for p in all_docs]
|
|
436
|
+
tmpl = project.templates_dir.resolve()
|
|
437
|
+
legacy = project.folder("legacy").resolve()
|
|
438
|
+
targets = [p for p in targets if p.suffix == ".md" and p.exists() and tmpl not in p.parents and legacy not in p.parents]
|
|
439
|
+
findings: list[Finding] = []
|
|
440
|
+
target_set = set(targets)
|
|
441
|
+
for p in targets:
|
|
442
|
+
findings += check_file(project, p, read(p), ctx)
|
|
443
|
+
# duplicate ids need every doc, not only the targets
|
|
444
|
+
if paths is not None:
|
|
445
|
+
for p in all_docs:
|
|
446
|
+
if p.resolve() in target_set:
|
|
447
|
+
continue
|
|
448
|
+
meta = frontmatter.parse(read(p)).meta
|
|
449
|
+
if meta.get("id") and meta.get("type"):
|
|
450
|
+
ctx.ids[(str(meta["type"]), str(meta["id"]))].append(project.rel(p))
|
|
451
|
+
target_rels = {project.rel(p) for p in targets}
|
|
452
|
+
for (_doc_type, doc_id), files in ctx.ids.items():
|
|
453
|
+
if len(files) > 1:
|
|
454
|
+
for f in files:
|
|
455
|
+
if f in target_rels:
|
|
456
|
+
others = ", ".join(x for x in files if x != f)
|
|
457
|
+
findings.append(Finding("error", f, "dup-id", f"ID {doc_id}가 다른 문서와 겹칩니다: {others}", 1))
|
|
458
|
+
# task ids across all task files
|
|
459
|
+
seen: dict[str, str] = {}
|
|
460
|
+
for p in all_docs:
|
|
461
|
+
if nfc(p.name) != "할 일.md":
|
|
462
|
+
continue
|
|
463
|
+
for t in parse_tasks(read(p)):
|
|
464
|
+
r = project.rel(p)
|
|
465
|
+
if t.id in seen and seen[t.id] != r and r in target_rels:
|
|
466
|
+
findings.append(Finding("error", r, "task-dup", f"할 일 ID {t.id}가 {seen[t.id]}에도 있습니다", t.line_no + 1))
|
|
467
|
+
seen.setdefault(t.id, r)
|
|
468
|
+
return Report(findings=findings, files=len(targets))
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""`lantern doctor` — does the project's real structure match lantern.yaml? One look at everything."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
|
|
7
|
+
from ..core import profile as profile_mod
|
|
8
|
+
from ..core import state
|
|
9
|
+
from ..core.project import Project, git, git_version, read
|
|
10
|
+
from ..tools.rules import AGENTS_START
|
|
11
|
+
from ..tools.skills import SKILL_NAMES
|
|
12
|
+
from . import githooks
|
|
13
|
+
|
|
14
|
+
OK, WARN, BAD = "✔", "⚠", "✖"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def run(project: Project) -> list[tuple[str, str]]:
|
|
18
|
+
out: list[tuple[str, str]] = []
|
|
19
|
+
p = project.profile
|
|
20
|
+
if not p.exists:
|
|
21
|
+
out.append((BAD, "lantern.yaml이 없습니다 — /l-init 으로 프로젝트를 준비하세요"))
|
|
22
|
+
else:
|
|
23
|
+
problems = profile_mod.validate(p.data)
|
|
24
|
+
out += [(BAD, f"lantern.yaml: {x}") for x in problems] or [(OK, "lantern.yaml 형식")]
|
|
25
|
+
if shutil.which("lantern"):
|
|
26
|
+
out.append((OK, "lantern 명령이 PATH에 있음"))
|
|
27
|
+
else:
|
|
28
|
+
out.append((WARN, "lantern 명령이 PATH에 없습니다 — 에이전트 hook · git hook이 건너뜁니다 (uv tool install <lanternspec 경로>)"))
|
|
29
|
+
is_git = git(["rev-parse", "--git-dir"], project.root).returncode == 0
|
|
30
|
+
if not is_git:
|
|
31
|
+
out.append((WARN, "git 저장소가 아닙니다 — hook · 커밋 연결 · sync가 동작하지 않습니다"))
|
|
32
|
+
else:
|
|
33
|
+
ver = git_version(project.root)
|
|
34
|
+
if p.remote == "local" and ver < (2, 42, 0):
|
|
35
|
+
out.append((BAD, f"local 방식은 git 2.42 이상 필요 (지금 {'.'.join(map(str, ver))})"))
|
|
36
|
+
structural = githooks.structure_problems(project)
|
|
37
|
+
out += [(BAD, f"문서 저장 방식({p.remote}): {x}") for x in structural] or [(OK, f"문서 저장 방식 {p.remote} — 구조가 맞음")]
|
|
38
|
+
hooks = githooks.status(project)
|
|
39
|
+
missing = [n for n, ok in hooks if not ok]
|
|
40
|
+
if missing:
|
|
41
|
+
level = BAD if p.remote == "local" and "pre-push" in missing else WARN
|
|
42
|
+
out.append((level, f"git hook 미설치: {', '.join(missing)} — lantern hooks install"))
|
|
43
|
+
else:
|
|
44
|
+
out.append((OK, f"git hook 설치됨 (내용 검사: {p.get('git.hooks')})"))
|
|
45
|
+
if p.get("project.kind"):
|
|
46
|
+
out.append(_code_size_row(project))
|
|
47
|
+
if p.exists:
|
|
48
|
+
out.append(
|
|
49
|
+
(OK, "docs/0. 템플릿 있음") if project.templates_dir.exists() else (BAD, "docs/0. 템플릿이 없습니다 — lantern scaffold init")
|
|
50
|
+
)
|
|
51
|
+
out.append((OK, "enums.yaml 있음") if project.enums_file.exists() else (WARN, "enums.yaml이 없습니다 — 기본값을 씁니다"))
|
|
52
|
+
out += [(BAD, x) for x in project.enums.problems()]
|
|
53
|
+
out.append((OK, "개발문서 MOC 있음") if project.moc.exists() else (WARN, "MOC가 없습니다 — lantern map"))
|
|
54
|
+
k = p.knowledge
|
|
55
|
+
if k:
|
|
56
|
+
from pathlib import Path
|
|
57
|
+
|
|
58
|
+
if k["type"] == "notion":
|
|
59
|
+
import os
|
|
60
|
+
|
|
61
|
+
env = str(k.get("token_env") or "NOTION_TOKEN")
|
|
62
|
+
out.append((OK, f"Notion 토큰 {env} 있음") if os.environ.get(env) else (WARN, f"Notion 토큰이 없습니다 — 환경 변수 {env}"))
|
|
63
|
+
else:
|
|
64
|
+
path = Path(str(k["path"])).expanduser()
|
|
65
|
+
out.append((OK, f"지식 저장소 {path} ({k['type']})") if path.exists() else (BAD, f"지식 저장소 경로가 없습니다: {path}"))
|
|
66
|
+
st = state.load(project)
|
|
67
|
+
if not st.get("version"):
|
|
68
|
+
out.append((WARN, "스킬이 설치되지 않았습니다 — lantern install"))
|
|
69
|
+
else:
|
|
70
|
+
from ..tools.install import drift
|
|
71
|
+
|
|
72
|
+
d = drift(project)
|
|
73
|
+
if d["missing"]:
|
|
74
|
+
out.append((WARN, f"빠진 스킬 파일 {len(d['missing'])}개 — lantern upgrade"))
|
|
75
|
+
if d["stale"]:
|
|
76
|
+
out.append((WARN, f"lantern.yaml 값(예: idea.capture)과 스킬이 다릅니다 {len(d['stale'])}개 — lantern upgrade"))
|
|
77
|
+
if d["modified"]:
|
|
78
|
+
out.append((WARN, f"사용자가 고친 스킬 {len(d['modified'])}개 (upgrade가 건너뜀): " + ", ".join(d["modified"][:3])))
|
|
79
|
+
if not any(d.values()):
|
|
80
|
+
out.append((OK, f"스킬 {len(SKILL_NAMES)}개 최신 ({', '.join(st.get('tools') or [])})"))
|
|
81
|
+
agents = project.root / "AGENTS.md"
|
|
82
|
+
if not agents.exists() or AGENTS_START not in read(agents):
|
|
83
|
+
out.append((WARN, "AGENTS.md에 LanternSpec 규칙이 없습니다 — lantern upgrade"))
|
|
84
|
+
if "claude" in (st.get("tools") or []):
|
|
85
|
+
cm = project.root / "CLAUDE.md"
|
|
86
|
+
if not cm.exists() or "@AGENTS.md" not in read(cm):
|
|
87
|
+
out.append((WARN, "CLAUDE.md가 @AGENTS.md를 불러오지 않습니다"))
|
|
88
|
+
return out
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _code_size_row(project: Project) -> tuple[str, str]:
|
|
92
|
+
"""코드가 자라 기록한 규모의 경계를 넘었으면 원칙을 한 단계 올릴지 묻는다 (줄면 그대로 둔다)."""
|
|
93
|
+
from ..core import principles
|
|
94
|
+
from ..report.harness import code_lines
|
|
95
|
+
|
|
96
|
+
lines = code_lines(project, tests=False)
|
|
97
|
+
now, recorded = principles.code_size(lines), str(project.profile.get("project.code_size") or principles.SIZES[0])
|
|
98
|
+
label = principles.table()["sizes"]["code"]
|
|
99
|
+
if principles.SIZES.index(now) > principles.SIZES.index(recorded):
|
|
100
|
+
return (
|
|
101
|
+
WARN,
|
|
102
|
+
f"코드가 {lines:,}줄로 자라 규모가 {label[recorded]['label']} → {label[now]['label']}이 됐습니다 — 원칙을 한 단계 올릴까요? "
|
|
103
|
+
f"→ lantern profile set project.code_size={now} && lantern upgrade",
|
|
104
|
+
)
|
|
105
|
+
return OK, f"개발 원칙 규모: 코드 {label[recorded]['label']} ({lines:,}줄)"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def format_result(rows: list[tuple[str, str]]) -> str:
|
|
109
|
+
bad = sum(1 for r in rows if r[0] == BAD)
|
|
110
|
+
warn = sum(1 for r in rows if r[0] == WARN)
|
|
111
|
+
lines = [f"{icon} {msg}" for icon, msg in rows]
|
|
112
|
+
lines.append("" if not rows else ("✔ 모두 정상" if not bad and not warn else f"✖ {bad}개 · ⚠ {warn}개"))
|
|
113
|
+
return "\n".join(lines).rstrip()
|