dotmd-parser 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.
- dotmd_parser/__init__.py +33 -0
- dotmd_parser/parser.py +576 -0
- dotmd_parser-0.1.0.dist-info/METADATA +121 -0
- dotmd_parser-0.1.0.dist-info/RECORD +8 -0
- dotmd_parser-0.1.0.dist-info/WHEEL +5 -0
- dotmd_parser-0.1.0.dist-info/entry_points.txt +2 -0
- dotmd_parser-0.1.0.dist-info/licenses/LICENSE +21 -0
- dotmd_parser-0.1.0.dist-info/top_level.txt +1 -0
dotmd_parser/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dotmd-parser — Dependency graph parser for .md skill files.
|
|
3
|
+
|
|
4
|
+
Parse @include/@delegate directives, build dependency graphs,
|
|
5
|
+
and resolve templates for AI agent prompt engineering.
|
|
6
|
+
|
|
7
|
+
API:
|
|
8
|
+
from dotmd_parser import build_graph, resolve, dependents_of, summary
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
|
|
13
|
+
from dotmd_parser.parser import (
|
|
14
|
+
build_graph,
|
|
15
|
+
resolve,
|
|
16
|
+
dependents_of,
|
|
17
|
+
parse_directives,
|
|
18
|
+
parse_read_refs,
|
|
19
|
+
parse_placeholders,
|
|
20
|
+
parse_deps_yml,
|
|
21
|
+
summary,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"build_graph",
|
|
26
|
+
"resolve",
|
|
27
|
+
"dependents_of",
|
|
28
|
+
"parse_directives",
|
|
29
|
+
"parse_read_refs",
|
|
30
|
+
"parse_placeholders",
|
|
31
|
+
"parse_deps_yml",
|
|
32
|
+
"summary",
|
|
33
|
+
]
|
dotmd_parser/parser.py
ADDED
|
@@ -0,0 +1,576 @@
|
|
|
1
|
+
"""
|
|
2
|
+
dotMD — .md skill dependency parser
|
|
3
|
+
入力: スキルのルートディレクトリ(またはSKILL.mdのパス)
|
|
4
|
+
出力: { nodes, edges, warnings } の辞書
|
|
5
|
+
|
|
6
|
+
追加機能:
|
|
7
|
+
- resolve() : @include を展開した最終テキストを出力
|
|
8
|
+
- parse_placeholders() : {{variable}} を検出
|
|
9
|
+
- dependents_of() : 逆依存(影響範囲)の問い合わせ
|
|
10
|
+
- カスタムノード型マッピング対応
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import re
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
# ディレクティブのパターン
|
|
18
|
+
# @include path/to/file.md
|
|
19
|
+
# @delegate path/to/agent.md
|
|
20
|
+
# @delegate path/to/agent.md --parallel
|
|
21
|
+
DIRECTIVE_PATTERN = re.compile(
|
|
22
|
+
r'^\s*@(include|delegate)\s+([\w./_-]+\.md)(\s+--parallel)?\s*$',
|
|
23
|
+
re.MULTILINE
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
# Read 参照のパターン(ランタイム依存 — 展開しない)
|
|
27
|
+
# Read `path/to/file.md` for ...
|
|
28
|
+
# See `path/to/file.md` for ...
|
|
29
|
+
# - `path/to/file.md` — description
|
|
30
|
+
# パスに / を含む .md ファイルのみ対象(誤検出防止)
|
|
31
|
+
READ_REF_PATTERN = re.compile(
|
|
32
|
+
r'(?:Read|See)\s+[`"\']([^`"\']*?/[^`"\']+\.md)[`"\']'
|
|
33
|
+
r'|'
|
|
34
|
+
r'^\s*-\s+[`"\']([^`"\']*?/[^`"\']+\.md)[`"\']',
|
|
35
|
+
re.MULTILINE,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# プレースホルダーのパターン: {{variableName}}
|
|
39
|
+
PLACEHOLDER_PATTERN = re.compile(r'\{\{(\w+)\}\}')
|
|
40
|
+
|
|
41
|
+
MAX_DEPTH = 10
|
|
42
|
+
|
|
43
|
+
# ============================================================
|
|
44
|
+
# deps.yml パーサー(軽量YAML — PyYAML不要)
|
|
45
|
+
# ============================================================
|
|
46
|
+
|
|
47
|
+
def parse_deps_yml(content: str) -> dict[str, list[str]]:
|
|
48
|
+
"""deps.yml のテキストをパースする(PyYAML不要の軽量パーサー)。"""
|
|
49
|
+
result: dict[str, list[str]] = {}
|
|
50
|
+
current_path = None
|
|
51
|
+
in_includes = False
|
|
52
|
+
|
|
53
|
+
for line in content.splitlines():
|
|
54
|
+
stripped = line.strip()
|
|
55
|
+
|
|
56
|
+
if not stripped or stripped.startswith("#"):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
path_match = re.match(r'^-?\s*path:\s*(.+)$', stripped)
|
|
60
|
+
if path_match:
|
|
61
|
+
current_path = path_match.group(1).strip().strip('"').strip("'")
|
|
62
|
+
result[current_path] = []
|
|
63
|
+
in_includes = False
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
if stripped == "includes:" or stripped == "includes: []":
|
|
67
|
+
in_includes = stripped == "includes:"
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
if in_includes and current_path and stripped.startswith("- "):
|
|
71
|
+
include_path = stripped[2:].strip().strip('"').strip("'")
|
|
72
|
+
if " #" in include_path:
|
|
73
|
+
include_path = include_path[:include_path.index(" #")].strip().strip('"').strip("'")
|
|
74
|
+
if include_path:
|
|
75
|
+
result[current_path].append(include_path)
|
|
76
|
+
continue
|
|
77
|
+
|
|
78
|
+
if ":" in stripped:
|
|
79
|
+
in_includes = False
|
|
80
|
+
|
|
81
|
+
return result
|
|
82
|
+
|
|
83
|
+
# デフォルトのノード型マッピング(パスキーワード → 型名)
|
|
84
|
+
# 順序が重要: 先にマッチしたものが優先
|
|
85
|
+
DEFAULT_TYPE_MAP = [
|
|
86
|
+
("agent", "agent"),
|
|
87
|
+
("shared", "shared"),
|
|
88
|
+
("prompt", "prompt"),
|
|
89
|
+
("reference", "reference"),
|
|
90
|
+
("asset", "template"),
|
|
91
|
+
("template", "template"),
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_directives(content: str) -> list[dict]:
|
|
96
|
+
"""ファイル内容から @include / @delegate を抽出する"""
|
|
97
|
+
results = []
|
|
98
|
+
for match in DIRECTIVE_PATTERN.finditer(content):
|
|
99
|
+
results.append({
|
|
100
|
+
"type": match.group(1), # "include" or "delegate"
|
|
101
|
+
"target": match.group(2), # 参照先のパス
|
|
102
|
+
"parallel": bool(match.group(3)), # --parallel フラグ
|
|
103
|
+
})
|
|
104
|
+
return results
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def parse_read_refs(content: str) -> list[str]:
|
|
108
|
+
"""ファイル内容から Read/See/リスト形式の .md 参照を抽出する(重複排除・出現順)"""
|
|
109
|
+
seen = set()
|
|
110
|
+
result = []
|
|
111
|
+
for match in READ_REF_PATTERN.finditer(content):
|
|
112
|
+
# 2つの選択肢グループがあるため、最初に非Noneの方を取得
|
|
113
|
+
target = match.group(1) or match.group(2)
|
|
114
|
+
if target and target not in seen:
|
|
115
|
+
seen.add(target)
|
|
116
|
+
result.append(target)
|
|
117
|
+
return result
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def parse_placeholders(content: str) -> list[str]:
|
|
121
|
+
"""ファイル内容から {{variable}} プレースホルダーを抽出する(重複排除・出現順)"""
|
|
122
|
+
seen = set()
|
|
123
|
+
result = []
|
|
124
|
+
for match in PLACEHOLDER_PATTERN.finditer(content):
|
|
125
|
+
name = match.group(1)
|
|
126
|
+
if name not in seen:
|
|
127
|
+
seen.add(name)
|
|
128
|
+
result.append(name)
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def build_graph(root_path: str, type_map: list[tuple[str, str]] | None = None) -> dict:
|
|
133
|
+
"""
|
|
134
|
+
ルートのSKILL.md(または任意の.mdファイル / ディレクトリ)を起点に
|
|
135
|
+
依存グラフを構築する。deps.yml が存在する場合は統合する。
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
root_path: ディレクトリ、SKILL.md、または任意の.mdファイルのパス
|
|
139
|
+
type_map: ノード型推定のカスタムマッピング。
|
|
140
|
+
[(パスキーワード, 型名), ...] のリスト。
|
|
141
|
+
None の場合はデフォルトマッピングを使用。
|
|
142
|
+
|
|
143
|
+
返り値:
|
|
144
|
+
{
|
|
145
|
+
"nodes": [{"id": "...", "type": "...", "missing": false, "placeholders": ["var1"]}],
|
|
146
|
+
"edges": [{"from": "...", "to": "...", "type": "include", "parallel": false}],
|
|
147
|
+
"warnings": [{"type": "circular|missing|depth_exceeded", "path": "...", "message": "..."}]
|
|
148
|
+
}
|
|
149
|
+
"""
|
|
150
|
+
root = Path(root_path)
|
|
151
|
+
mapping = type_map if type_map is not None else DEFAULT_TYPE_MAP
|
|
152
|
+
|
|
153
|
+
# ディレクトリが渡された場合の deps.yml パスを記録
|
|
154
|
+
base_dir = root if root.is_dir() else root.parent
|
|
155
|
+
|
|
156
|
+
# ディレクトリが渡された場合はSKILL.mdを探す
|
|
157
|
+
has_skill_md = True
|
|
158
|
+
if root.is_dir():
|
|
159
|
+
candidate = root / "SKILL.md"
|
|
160
|
+
if not candidate.exists():
|
|
161
|
+
# 大文字小文字を無視して探す
|
|
162
|
+
candidates = list(root.glob("*.md"))
|
|
163
|
+
skill_files = [f for f in candidates if f.name.upper() == "SKILL.MD"]
|
|
164
|
+
if skill_files:
|
|
165
|
+
candidate = skill_files[0]
|
|
166
|
+
else:
|
|
167
|
+
has_skill_md = False
|
|
168
|
+
candidate = None
|
|
169
|
+
|
|
170
|
+
if candidate:
|
|
171
|
+
root = candidate
|
|
172
|
+
|
|
173
|
+
nodes = {} # id -> node dict(重複排除用)
|
|
174
|
+
edges = []
|
|
175
|
+
warnings = []
|
|
176
|
+
visited_stack = [] # 循環参照検出用(DFS スタック)
|
|
177
|
+
|
|
178
|
+
def _infer_node_type(path: Path) -> str:
|
|
179
|
+
"""パスからノード種別を推定する"""
|
|
180
|
+
path_lower = str(path).lower()
|
|
181
|
+
name = path.name.lower()
|
|
182
|
+
if name == "skill.md":
|
|
183
|
+
return "skill"
|
|
184
|
+
for keyword, node_type in mapping:
|
|
185
|
+
if keyword in path_lower:
|
|
186
|
+
return node_type
|
|
187
|
+
return "reference"
|
|
188
|
+
|
|
189
|
+
def _resolve(current_file: Path, target: str) -> Path:
|
|
190
|
+
"""相対パスを絶対パスに解決する"""
|
|
191
|
+
return (current_file.parent / target).resolve()
|
|
192
|
+
|
|
193
|
+
def _walk(file_path: Path, depth: int):
|
|
194
|
+
rel = str(file_path)
|
|
195
|
+
|
|
196
|
+
# 深さ制限
|
|
197
|
+
if depth > MAX_DEPTH:
|
|
198
|
+
warnings.append({
|
|
199
|
+
"type": "depth_exceeded",
|
|
200
|
+
"path": rel,
|
|
201
|
+
"message": f"最大深さ {MAX_DEPTH} を超えました"
|
|
202
|
+
})
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
# 循環参照チェック
|
|
206
|
+
if rel in visited_stack:
|
|
207
|
+
cycle_path = " -> ".join(visited_stack + [rel])
|
|
208
|
+
warnings.append({
|
|
209
|
+
"type": "circular",
|
|
210
|
+
"path": rel,
|
|
211
|
+
"message": f"循環参照: {cycle_path}"
|
|
212
|
+
})
|
|
213
|
+
return
|
|
214
|
+
|
|
215
|
+
# ファイル存在チェック
|
|
216
|
+
if not file_path.exists():
|
|
217
|
+
warnings.append({
|
|
218
|
+
"type": "missing",
|
|
219
|
+
"path": rel,
|
|
220
|
+
"message": f"参照先ファイルが存在しません: {rel}"
|
|
221
|
+
})
|
|
222
|
+
# ノードは追加(欠損として記録)
|
|
223
|
+
if rel not in nodes:
|
|
224
|
+
nodes[rel] = {"id": rel, "type": _infer_node_type(file_path), "missing": True, "placeholders": []}
|
|
225
|
+
return
|
|
226
|
+
|
|
227
|
+
# ノード登録
|
|
228
|
+
if rel not in nodes:
|
|
229
|
+
nodes[rel] = {"id": rel, "type": _infer_node_type(file_path), "missing": False, "placeholders": []}
|
|
230
|
+
|
|
231
|
+
# 既訪問ならエッジのみ追加して終了(ノードの中身は再帰しない)
|
|
232
|
+
if rel in [n["id"] for n in nodes.values() if not n.get("_unvisited", True)]:
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
nodes[rel]["_unvisited"] = False
|
|
236
|
+
|
|
237
|
+
# ファイル読み込み
|
|
238
|
+
try:
|
|
239
|
+
content = file_path.read_text(encoding="utf-8")
|
|
240
|
+
except Exception as e:
|
|
241
|
+
warnings.append({
|
|
242
|
+
"type": "read_error",
|
|
243
|
+
"path": rel,
|
|
244
|
+
"message": str(e)
|
|
245
|
+
})
|
|
246
|
+
return
|
|
247
|
+
|
|
248
|
+
# プレースホルダー検出
|
|
249
|
+
nodes[rel]["placeholders"] = parse_placeholders(content)
|
|
250
|
+
|
|
251
|
+
# ディレクティブ抽出 & 再帰
|
|
252
|
+
visited_stack.append(rel)
|
|
253
|
+
for directive in parse_directives(content):
|
|
254
|
+
target_path = _resolve(file_path, directive["target"])
|
|
255
|
+
target_rel = str(target_path)
|
|
256
|
+
|
|
257
|
+
# エッジ追加(重複チェック)
|
|
258
|
+
edge = {
|
|
259
|
+
"from": rel,
|
|
260
|
+
"to": target_rel,
|
|
261
|
+
"type": directive["type"],
|
|
262
|
+
"parallel": directive["parallel"],
|
|
263
|
+
}
|
|
264
|
+
if edge not in edges:
|
|
265
|
+
edges.append(edge)
|
|
266
|
+
|
|
267
|
+
_walk(target_path, depth + 1)
|
|
268
|
+
|
|
269
|
+
# Read 参照の検出(ランタイム依存 — 再帰しない)
|
|
270
|
+
for read_target in parse_read_refs(content):
|
|
271
|
+
# ファイルの親ディレクトリからの相対パス → 絶対パス
|
|
272
|
+
target_path = _resolve(file_path, read_target)
|
|
273
|
+
target_rel = str(target_path)
|
|
274
|
+
|
|
275
|
+
# 解決できない場合は祖先ディレクトリを遡って探索
|
|
276
|
+
if not target_path.exists():
|
|
277
|
+
search_dir = file_path.parent
|
|
278
|
+
while search_dir != search_dir.parent:
|
|
279
|
+
alt_path = (search_dir / read_target).resolve()
|
|
280
|
+
if alt_path.exists():
|
|
281
|
+
target_path = alt_path
|
|
282
|
+
target_rel = str(alt_path)
|
|
283
|
+
break
|
|
284
|
+
search_dir = search_dir.parent
|
|
285
|
+
|
|
286
|
+
# エッジ追加(重複チェック)
|
|
287
|
+
edge = {
|
|
288
|
+
"from": rel,
|
|
289
|
+
"to": target_rel,
|
|
290
|
+
"type": "read-ref",
|
|
291
|
+
"parallel": False,
|
|
292
|
+
}
|
|
293
|
+
if edge not in edges:
|
|
294
|
+
edges.append(edge)
|
|
295
|
+
|
|
296
|
+
# ノード登録(再帰はしない)
|
|
297
|
+
if target_rel not in nodes:
|
|
298
|
+
is_missing = not target_path.exists()
|
|
299
|
+
nodes[target_rel] = {
|
|
300
|
+
"id": target_rel,
|
|
301
|
+
"type": _infer_node_type(target_path),
|
|
302
|
+
"missing": is_missing,
|
|
303
|
+
"placeholders": [],
|
|
304
|
+
}
|
|
305
|
+
if is_missing:
|
|
306
|
+
warnings.append({
|
|
307
|
+
"type": "missing",
|
|
308
|
+
"path": target_rel,
|
|
309
|
+
"message": f"Read参照先が存在しません: {read_target}",
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
visited_stack.pop()
|
|
313
|
+
|
|
314
|
+
if has_skill_md and root:
|
|
315
|
+
_walk(root.resolve(), 0)
|
|
316
|
+
elif not has_skill_md:
|
|
317
|
+
# SKILL.md がない場合は deps.yml のみで動作する可能性がある
|
|
318
|
+
pass
|
|
319
|
+
|
|
320
|
+
# deps.yml の読み込み・統合
|
|
321
|
+
deps_yml_path = base_dir / "deps.yml"
|
|
322
|
+
if deps_yml_path.exists():
|
|
323
|
+
try:
|
|
324
|
+
deps_content = deps_yml_path.read_text(encoding="utf-8")
|
|
325
|
+
deps = parse_deps_yml(deps_content)
|
|
326
|
+
|
|
327
|
+
for from_file, includes in deps.items():
|
|
328
|
+
from_path = (base_dir / from_file).resolve()
|
|
329
|
+
from_rel = str(from_path)
|
|
330
|
+
|
|
331
|
+
# ノード登録
|
|
332
|
+
if from_rel not in nodes:
|
|
333
|
+
node_type = _infer_node_type(from_path)
|
|
334
|
+
is_missing = not from_path.exists()
|
|
335
|
+
nodes[from_rel] = {
|
|
336
|
+
"id": from_rel,
|
|
337
|
+
"type": node_type if node_type != "reference" else "document",
|
|
338
|
+
"missing": is_missing,
|
|
339
|
+
"placeholders": [],
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for to_file in includes:
|
|
343
|
+
to_path = (base_dir / to_file).resolve()
|
|
344
|
+
to_rel = str(to_path)
|
|
345
|
+
|
|
346
|
+
# 参照先ノード登録
|
|
347
|
+
if to_rel not in nodes:
|
|
348
|
+
is_missing = not to_path.exists()
|
|
349
|
+
node_type = _infer_node_type(to_path)
|
|
350
|
+
nodes[to_rel] = {
|
|
351
|
+
"id": to_rel,
|
|
352
|
+
"type": node_type if node_type != "reference" else "document",
|
|
353
|
+
"missing": is_missing,
|
|
354
|
+
"placeholders": [],
|
|
355
|
+
}
|
|
356
|
+
if is_missing:
|
|
357
|
+
warnings.append({
|
|
358
|
+
"type": "missing",
|
|
359
|
+
"path": to_rel,
|
|
360
|
+
"message": f"deps.yml の参照先が存在しません: {to_file}",
|
|
361
|
+
})
|
|
362
|
+
|
|
363
|
+
# エッジ登録(重複チェック)
|
|
364
|
+
edge = {
|
|
365
|
+
"from": from_rel,
|
|
366
|
+
"to": to_rel,
|
|
367
|
+
"type": "include",
|
|
368
|
+
"parallel": False,
|
|
369
|
+
}
|
|
370
|
+
if edge not in edges:
|
|
371
|
+
edges.append(edge)
|
|
372
|
+
|
|
373
|
+
except Exception as e:
|
|
374
|
+
warnings.append({
|
|
375
|
+
"type": "read_error",
|
|
376
|
+
"path": str(deps_yml_path),
|
|
377
|
+
"message": f"deps.yml 読み込みエラー: {e}",
|
|
378
|
+
})
|
|
379
|
+
|
|
380
|
+
# SKILL.md も deps.yml もない場合
|
|
381
|
+
if not nodes and not edges:
|
|
382
|
+
if not has_skill_md:
|
|
383
|
+
warnings.append({
|
|
384
|
+
"type": "missing",
|
|
385
|
+
"path": str(base_dir),
|
|
386
|
+
"message": "SKILL.md も deps.yml も見つかりません",
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
# _unvisited フラグをクリーンアップ
|
|
390
|
+
for node in nodes.values():
|
|
391
|
+
node.pop("_unvisited", None)
|
|
392
|
+
|
|
393
|
+
return {
|
|
394
|
+
"nodes": list(nodes.values()),
|
|
395
|
+
"edges": edges,
|
|
396
|
+
"warnings": warnings,
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
# ============================================================
|
|
401
|
+
# resolve() — @include 展開
|
|
402
|
+
# ============================================================
|
|
403
|
+
|
|
404
|
+
def resolve(file_path: str, variables: dict[str, str] | None = None) -> dict:
|
|
405
|
+
"""
|
|
406
|
+
@include ディレクティブを再帰的に展開し、最終テキストを生成する。
|
|
407
|
+
@delegate 行はそのまま残す(実行時に別エージェントが処理するため)。
|
|
408
|
+
|
|
409
|
+
Args:
|
|
410
|
+
file_path: 起点となる .md ファイルのパス
|
|
411
|
+
variables: {{key}} を置換する辞書。None の場合はプレースホルダーをそのまま残す。
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
{
|
|
415
|
+
"content": 展開後の最終テキスト,
|
|
416
|
+
"placeholders": 展開後に残っている未解決プレースホルダー名のリスト,
|
|
417
|
+
"warnings": 処理中の警告リスト
|
|
418
|
+
}
|
|
419
|
+
"""
|
|
420
|
+
root = Path(file_path).resolve()
|
|
421
|
+
warnings = []
|
|
422
|
+
visited_stack = []
|
|
423
|
+
|
|
424
|
+
def _expand(fp: Path, depth: int) -> str:
|
|
425
|
+
rel = str(fp)
|
|
426
|
+
|
|
427
|
+
if depth > MAX_DEPTH:
|
|
428
|
+
warnings.append({"type": "depth_exceeded", "path": rel, "message": f"最大深さ {MAX_DEPTH} を超えました"})
|
|
429
|
+
return ""
|
|
430
|
+
|
|
431
|
+
if rel in visited_stack:
|
|
432
|
+
warnings.append({"type": "circular", "path": rel, "message": f"循環参照: {' -> '.join(visited_stack + [rel])}"})
|
|
433
|
+
return ""
|
|
434
|
+
|
|
435
|
+
if not fp.exists():
|
|
436
|
+
warnings.append({"type": "missing", "path": rel, "message": f"参照先ファイルが存在しません: {rel}"})
|
|
437
|
+
return ""
|
|
438
|
+
|
|
439
|
+
try:
|
|
440
|
+
content = fp.read_text(encoding="utf-8")
|
|
441
|
+
except Exception as e:
|
|
442
|
+
warnings.append({"type": "read_error", "path": rel, "message": str(e)})
|
|
443
|
+
return ""
|
|
444
|
+
|
|
445
|
+
visited_stack.append(rel)
|
|
446
|
+
|
|
447
|
+
# @include 行を展開後の内容で置換する
|
|
448
|
+
def _replace_include(match):
|
|
449
|
+
directive_type = match.group(1)
|
|
450
|
+
target = match.group(2)
|
|
451
|
+
if directive_type != "include":
|
|
452
|
+
# @delegate はそのまま残す
|
|
453
|
+
return match.group(0)
|
|
454
|
+
target_path = (fp.parent / target).resolve()
|
|
455
|
+
return _expand(target_path, depth + 1)
|
|
456
|
+
|
|
457
|
+
result = DIRECTIVE_PATTERN.sub(_replace_include, content)
|
|
458
|
+
visited_stack.pop()
|
|
459
|
+
return result
|
|
460
|
+
|
|
461
|
+
expanded = _expand(root, 0)
|
|
462
|
+
|
|
463
|
+
# 変数置換
|
|
464
|
+
if variables:
|
|
465
|
+
for key, value in variables.items():
|
|
466
|
+
expanded = expanded.replace(f"{{{{{key}}}}}", value)
|
|
467
|
+
|
|
468
|
+
# 未解決プレースホルダーを検出
|
|
469
|
+
remaining = parse_placeholders(expanded)
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
"content": expanded,
|
|
473
|
+
"placeholders": remaining,
|
|
474
|
+
"warnings": warnings,
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
# ============================================================
|
|
479
|
+
# dependents_of() — 逆依存の問い合わせ
|
|
480
|
+
# ============================================================
|
|
481
|
+
|
|
482
|
+
def dependents_of(graph: dict, target_id: str) -> list[str]:
|
|
483
|
+
"""
|
|
484
|
+
指定ノードに(直接・間接に)依存しているノードを返す。
|
|
485
|
+
「target_id を変更したら影響を受けるファイル」のリスト。
|
|
486
|
+
|
|
487
|
+
Args:
|
|
488
|
+
graph: build_graph() の返り値
|
|
489
|
+
target_id: 対象ノードの id(絶対パス文字列)
|
|
490
|
+
|
|
491
|
+
Returns:
|
|
492
|
+
依存元ノード id のリスト(ルートに近い順)
|
|
493
|
+
"""
|
|
494
|
+
# 逆隣接リストを構築
|
|
495
|
+
reverse_adj: dict[str, list[str]] = {}
|
|
496
|
+
for edge in graph["edges"]:
|
|
497
|
+
reverse_adj.setdefault(edge["to"], []).append(edge["from"])
|
|
498
|
+
|
|
499
|
+
# BFS で逆方向に辿る
|
|
500
|
+
visited = set()
|
|
501
|
+
queue = [target_id]
|
|
502
|
+
result = []
|
|
503
|
+
|
|
504
|
+
while queue:
|
|
505
|
+
current = queue.pop(0)
|
|
506
|
+
for parent in reverse_adj.get(current, []):
|
|
507
|
+
if parent not in visited:
|
|
508
|
+
visited.add(parent)
|
|
509
|
+
result.append(parent)
|
|
510
|
+
queue.append(parent)
|
|
511
|
+
|
|
512
|
+
return result
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
# ============================================================
|
|
516
|
+
# summary() — 改善版
|
|
517
|
+
# ============================================================
|
|
518
|
+
|
|
519
|
+
def summary(graph: dict) -> str:
|
|
520
|
+
"""グラフの概要を人間が読めるテキストで返す"""
|
|
521
|
+
nodes = graph["nodes"]
|
|
522
|
+
edges = graph["edges"]
|
|
523
|
+
warnings = graph["warnings"]
|
|
524
|
+
|
|
525
|
+
by_type: dict[str, int] = {}
|
|
526
|
+
for n in nodes:
|
|
527
|
+
t = n["type"]
|
|
528
|
+
by_type[t] = by_type.get(t, 0) + 1
|
|
529
|
+
|
|
530
|
+
# ノード型の表示(動的に存在する型のみ)
|
|
531
|
+
type_parts = [f"{k}:{v}" for k, v in sorted(by_type.items())]
|
|
532
|
+
type_str = ", ".join(type_parts) if type_parts else "none"
|
|
533
|
+
|
|
534
|
+
# エッジ型の集計
|
|
535
|
+
edge_types: dict[str, int] = {}
|
|
536
|
+
for e in edges:
|
|
537
|
+
edge_types[e["type"]] = edge_types.get(e["type"], 0) + 1
|
|
538
|
+
edge_parts = [f"{k}:{v}" for k, v in sorted(edge_types.items())]
|
|
539
|
+
edge_str = ", ".join(edge_parts) if edge_parts else "none"
|
|
540
|
+
|
|
541
|
+
# プレースホルダー集計
|
|
542
|
+
all_placeholders: set[str] = set()
|
|
543
|
+
for n in nodes:
|
|
544
|
+
for p in n.get("placeholders", []):
|
|
545
|
+
all_placeholders.add(p)
|
|
546
|
+
|
|
547
|
+
lines = [
|
|
548
|
+
f"ノード数: {len(nodes)} ({type_str})",
|
|
549
|
+
f"エッジ数: {len(edges)} ({edge_str})",
|
|
550
|
+
f"警告数: {len(warnings)}",
|
|
551
|
+
]
|
|
552
|
+
|
|
553
|
+
if all_placeholders:
|
|
554
|
+
lines.append(f"プレースホルダー: {', '.join(sorted(all_placeholders))}")
|
|
555
|
+
|
|
556
|
+
for w in warnings:
|
|
557
|
+
lines.append(f" [{w['type'].upper()}] {w['message']}")
|
|
558
|
+
return "\n".join(lines)
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def main():
|
|
562
|
+
import sys
|
|
563
|
+
|
|
564
|
+
args = sys.argv[1:]
|
|
565
|
+
if not args:
|
|
566
|
+
args = ["."]
|
|
567
|
+
|
|
568
|
+
target = args[0]
|
|
569
|
+
graph = build_graph(target)
|
|
570
|
+
print(summary(graph))
|
|
571
|
+
print("\n--- JSON ---")
|
|
572
|
+
print(json.dumps(graph, ensure_ascii=False, indent=2))
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
if __name__ == "__main__":
|
|
576
|
+
main()
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: dotmd-parser
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Dependency graph parser for .md skill files — parse @include/@delegate directives, build graphs, and resolve templates for AI agent prompt engineering
|
|
5
|
+
Author: dotmd-projects
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/dotmd-projects/dotmd-parser
|
|
8
|
+
Project-URL: Repository, https://github.com/dotmd-projects/dotmd-parser
|
|
9
|
+
Project-URL: Issues, https://github.com/dotmd-projects/dotmd-parser/issues
|
|
10
|
+
Keywords: claude-code,ai-agent,skill-management,prompt-engineering,dependency-graph,SKILL.md,markdown,parser,dotmd,llm
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
20
|
+
Classifier: Topic :: Software Development :: Code Generators
|
|
21
|
+
Classifier: Topic :: Text Processing :: Markup :: Markdown
|
|
22
|
+
Classifier: Typing :: Typed
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Description-Content-Type: text/markdown
|
|
25
|
+
License-File: LICENSE
|
|
26
|
+
Dynamic: license-file
|
|
27
|
+
|
|
28
|
+
# dotmd-parser
|
|
29
|
+
|
|
30
|
+
Dependency graph parser for `.md` skill files. Parses `@include` / `@delegate` directives and `Read` references, then builds a file dependency graph.
|
|
31
|
+
|
|
32
|
+
Designed for AI agent prompt engineering workflows — manage modular prompt skills with dependency tracking, template resolution, and impact analysis.
|
|
33
|
+
|
|
34
|
+
## Installation
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
pip install dotmd-parser
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
For development:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
git clone https://github.com/dotmd-projects/dotmd-parser.git
|
|
44
|
+
cd dotmd-parser
|
|
45
|
+
pip install -e .
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## Quick Start
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
from dotmd_parser import build_graph, resolve, dependents_of, summary
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### build_graph — Build a dependency graph
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
graph = build_graph("./my-skill/")
|
|
58
|
+
# or
|
|
59
|
+
graph = build_graph("./my-skill/SKILL.md")
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
```json
|
|
64
|
+
{
|
|
65
|
+
"nodes": [{"id": "...", "type": "skill", "missing": false, "placeholders": []}],
|
|
66
|
+
"edges": [{"from": "...", "to": "...", "type": "include", "parallel": false}],
|
|
67
|
+
"warnings": []
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### resolve — Expand @include directives
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
result = resolve("./prompts/main.md", variables={"name": "Alice"})
|
|
75
|
+
print(result["content"]) # Fully expanded text
|
|
76
|
+
print(result["placeholders"]) # Unresolved {{variable}} list
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
### dependents_of — Reverse dependency query
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
# Find all files affected by changes to shared/role.md
|
|
83
|
+
affected = dependents_of(graph, "/abs/path/to/shared/role.md")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### summary — Print graph overview
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
print(summary(graph))
|
|
90
|
+
# Nodes: 5 (agent:1, shared:2, skill:1, reference:1)
|
|
91
|
+
# Edges: 4 (include:3, read-ref:1)
|
|
92
|
+
# Warnings: 0
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Directives
|
|
96
|
+
|
|
97
|
+
| Directive | Description |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `@include path/to/file.md` | Inline expansion of the referenced file |
|
|
100
|
+
| `@delegate path/to/agent.md` | Delegate to an agent (no expansion) |
|
|
101
|
+
| `@delegate path/to/agent.md --parallel` | Delegate with parallel execution flag |
|
|
102
|
+
| `Read \`path/to/file.md\`` | Runtime reference (no expansion, recorded in graph) |
|
|
103
|
+
|
|
104
|
+
## CLI
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
dotmd-parser ./my-skill/
|
|
108
|
+
# or
|
|
109
|
+
python -m dotmd_parser.parser ./my-skill/
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Testing
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
pip install pytest
|
|
116
|
+
pytest tests/ -v
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## License
|
|
120
|
+
|
|
121
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
dotmd_parser/__init__.py,sha256=lqYT1uZTBHawJTtdJSfexsqfvVWSSLwUDfckGfvA2tI,657
|
|
2
|
+
dotmd_parser/parser.py,sha256=rZO9wM9hdRGZiq2r3s1bWF0VXPyESJu5rjeaBU25M3c,20134
|
|
3
|
+
dotmd_parser-0.1.0.dist-info/licenses/LICENSE,sha256=QPoevNEOt9cEANjY7t00O9L0X3qfMnDSSTtUYOIb3O8,1071
|
|
4
|
+
dotmd_parser-0.1.0.dist-info/METADATA,sha256=_A8PiBS5pUi5S9RgRS2gq--I7bmTG5iOoZNIQql00K4,3408
|
|
5
|
+
dotmd_parser-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
6
|
+
dotmd_parser-0.1.0.dist-info/entry_points.txt,sha256=Xh2qCpzTQb5pTlF2WA-w70ve-ygto0FyXeeBRpQCRCw,58
|
|
7
|
+
dotmd_parser-0.1.0.dist-info/top_level.txt,sha256=edsDegp9TiaISe-EmqDyF4S9HbMKOXVejjpvi-ZriEg,13
|
|
8
|
+
dotmd_parser-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 dotmd-projects
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
dotmd_parser
|