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,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"
@@ -0,0 +1,3 @@
1
+ from lanternspec.cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,4 @@
1
+ """검사와 양식 관리 — 양식 검사(check_docs) · git hook · doctor · 템플릿 버전(templates) · 기존 문서(legacy).
2
+
3
+ 여기를 고치는 이유: "맞다 · 틀리다"의 기준이나 양식 관리 규칙이 바뀔 때.
4
+ """
@@ -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()