dirlens 1.0.10 → 1.0.11

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 (3) hide show
  1. package/README.md +56 -3
  2. package/dirlens.py +755 -40
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -133,19 +133,46 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
133
133
  - **JSON出力** — `--json` / `-J` で機械可読な構造データを出力
134
134
  - **HTMLレポート** — `--html` でブラウザで閲覧できる折りたたみツリーを生成
135
135
  - **クリップボードコピー** — `-C` で出力を自動コピー(ANSIコードを自動除去)
136
- - **AIモード** — `--ai` 一発で gitignore除外・日時・Markdown・クリップボードコピーを全適用
136
+ - **AIチャット貼り付けモード** — `--ai` 一発で gitignore除外・日時・Markdown・クリップボードコピーを全適用(人間がコピペする用)
137
+ - **エージェント解析モード** — `--agent` 一発で下記のAI/エージェント向け解析機能を全適用(クリップボードは使わない、自律実行しても安全)
137
138
  - **隠しファイル対応** — `-a` で表示切り替え
138
139
  - **サイズ順ソート** — `-s` で大きいものから表示
139
140
 
141
+ ### AI/エージェント向け解析機能(`--agent` でまとめて有効化)
142
+
143
+ これらはAIチャットやコーディングエージェントがプロジェクト構造を理解する際に、ファイルの中身を逐一読まなくても済むよう設計された機能です。個別フラグでも使えます。
144
+
145
+ - **トークン数概算** — `-T` でファイルごとの推定トークン数を表示(英数字記号は約4文字/トークン、日本語等は約1.5文字/トークンとして概算)
146
+ - **git連携** — `-H` で各ファイルの最終コミット情報(メッセージ・相対日時)を表示。直近2000コミットまで走査
147
+ - **TODO/FIXME抽出** — `-K` で `TODO`/`FIXME`/`HACK`/`XXX` コメントを抽出し、行番号付きで一覧表示
148
+ - **テスト欠落検知** — `-V` で対応するテストファイルが見つからないソースファイルをマーク(命名規則ベースのヒューリスティック)
149
+ - **エントリーポイント検出** — `-N` で `main.py`・`index.js`・`package.json` の `main`/`bin` フィールドなどから入口ファイルを推測してマーク
150
+ - **シンボルアウトライン** — `-O` で関数・クラス名を簡易抽出(Python/JS/TS/Go/Rust対応、正規表現ベースのbest-effort)
151
+ - **import/依存グラフ** — `-M` でファイル間のローカルなimport関係を解析し、`imports×N`(依存先数)・`used-by×N`(被参照数)を表示。Pythonは標準ライブラリの`ast`で正確に解析、他言語は正規表現+パス解決
152
+
140
153
  ---
141
154
 
142
155
  ## 使い方
143
156
 
144
157
  ```bash
145
- # ── AI チャットへの貼り付け(最もよく使うコマンド)─────────────
158
+ # ── AI チャットへの貼り付け(人間がコピペする用)──────────────
146
159
  dirlens --ai # gitignore除外 + 日時 + Markdown + クリップボードコピー
147
160
  dirlens --ai -L 3 # 深さ指定と組み合わせ可
148
161
 
162
+ # ── エージェント向け解析(自律実行向け・クリップボードは使わない)──
163
+ dirlens --agent # トークン数・git情報・TODO・テスト欠落・
164
+ # エントリーポイント・アウトライン・import依存グラフを一括表示
165
+ dirlens --agent --json # 同上をJSON形式で(スクリプト/エージェント連携向け)
166
+
167
+ # ── AI/エージェント向け解析(個別フラグ)────────────────────────
168
+ dirlens -T # ファイルごとの推定トークン数
169
+ dirlens -H # 最終コミット情報(要git)
170
+ dirlens -K # TODO/FIXME/HACK/XXXを抽出
171
+ dirlens -V # テストが無いソースファイルを表示
172
+ dirlens -N # エントリーポイントらしきファイルをマーク
173
+ dirlens -O # 関数・クラスの簡易アウトライン
174
+ dirlens -M # ローカルなimport/依存関係を解析
175
+
149
176
  # ── 表示制御 ──────────────────────────────────────────────────
150
177
  dirlens # カレントディレクトリ
151
178
  dirlens ~/Desktop # 指定ディレクトリ
@@ -199,7 +226,8 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
199
226
  | オプション | 省略形 | 説明 |
200
227
  |---------------------|--------------|-------------------------------------------------------------|
201
228
  | `path` | — | 対象ディレクトリ(省略時はカレント) |
202
- | **`--ai`** | — | **`-G --date -m -C` のショートカット。AIチャット用** |
229
+ | **`--ai`** | — | **`-G --date -m -C` のショートカット。人間がAIチャットに貼り付ける用** |
230
+ | **`--agent`** | — | **`-G --date -T -H -K -V -N -O -M` のショートカット。エージェント向け解析(クリップボードは使わない)** |
203
231
  | `--depth N` | `-L N` | 表示する最大の深さ |
204
232
  | `--all` | `-a` | 隠しファイル・ディレクトリも表示 |
205
233
  | `-d` | — | ディレクトリのみ表示 |
@@ -224,6 +252,13 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
224
252
  | `--max-size SIZE` | — | 指定サイズ以下のファイルのみ表示 |
225
253
  | `--bar` | — | ディスク占有率バーを表示 |
226
254
  | `--emoji` | — | 拡張子に応じた絵文字アイコンを表示 |
255
+ | `--tokens` | `-T` | ファイルごとの推定トークン数を表示(概算) |
256
+ | `--git` | `-H` | 最終コミット情報を表示(要git、直近2000コミットまで走査) |
257
+ | `--todo` | `-K` | TODO/FIXME/HACK/XXXコメントを抽出 |
258
+ | `--missing-tests` | `-V` | 対応するテストファイルが見つからないソースファイルを表示 |
259
+ | `--entry` | `-N` | エントリーポイントらしきファイルを検出してマーク |
260
+ | `--outline` | `-O` | 関数・クラスの簡易アウトラインを表示(正規表現ベース・対応言語限定)|
261
+ | `--imports` | `-M` | ローカルなimport/依存関係を解析して表示(外部パッケージは対象外) |
227
262
  | `--markdown` | `-m` | Markdown コードブロック形式で出力(カラー自動無効) |
228
263
  | `--json` | `-J` | JSON 形式で標準出力に出力 |
229
264
  | `--html [FILE]` | — | HTML レポートを生成(デフォルト: `dirlens.html`) |
@@ -255,3 +290,21 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
255
290
  - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** — サイズ計算は表示深さに関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量ディレクトリで時間がかかります
256
291
  - **`-G` の否定パターン(`!`)** は対応していますが、ディレクトリを除外した後にその中のファイルを `!` で復活させることは非対応です(`*.log` + `!important.log` のようなファイル単位の否定は動作します)
257
292
  - `-p`(パーミッション)・`-u`(ユーザー名)・`-g`(グループ名)は macOS / Linux のみ対応(Windows では ID 番号を表示)
293
+
294
+ ### AI/エージェント向け解析機能の精度について
295
+
296
+ dirlens は外部ライブラリに依存しない方針で作られているため、以下の機能は**完全な精度を保証しません**。重要な判断の根拠にする場合は、該当ファイルの中身を直接確認することを推奨します。
297
+
298
+ | 機能 | 制限事項 |
299
+ |---|---|
300
+ | トークン数概算(`-T`) | 文字数ベースの大まかな目安。実際のトークナイザーとは一致しない |
301
+ | シンボルアウトライン(`-O`) | 正規表現ベースの簡易抽出(ASTではない)。デコレータが複雑な場合や複数行にまたがる関数シグネチャは取得漏れすることがある。対応言語は Python・JS/TS・Go・Rust のみ |
302
+ | import/依存グラフ(`-M`) | Pythonは標準ライブラリの `ast` で正確に解析されるため信頼度が高い。JS/TS/Go/Rustは正規表現+パス解決のため、JS/TSのbare import(`react`等)・Rustの`self::`/`super::`・外部crate・モジュール外のGoパッケージは解決されず「external」扱いになる |
303
+ | テスト欠落検知(`-V`) | ファイル命名規則のみで判定(`test_foo.py`等)。実際のテストカバレッジは見ていない。Rustは対象外(インラインテストの慣習のため検出不可) |
304
+ | エントリーポイント検出(`-N`) | 既知のファイル名パターンと `package.json` の `main`/`bin` フィールドのみで判定 |
305
+ | TODO/FIXME抽出(`-K`) | 単純な文字列マッチ。コメント外の文字列内に偶然該当語があっても拾われる場合がある |
306
+ | git連携(`-H`) | 直近2000コミットのみ走査。それより古い変更しかないファイルは情報が出ない |
307
+
308
+ ### AIエージェントへの指示テンプレート
309
+
310
+ エージェント(Claude Code・Cursor等)にプロジェクト探索の手順として `dirlens --agent` を使わせたい場合、`AGENT_RULE.md` のテンプレートを `CLAUDE.md`・`.cursorrules` 等のグローバルルールファイルにそのまま貼り付けて使えます。
package/dirlens.py CHANGED
@@ -4,7 +4,7 @@ dirlens – ファイルサイズ付きディレクトリツリー表示ツー
4
4
  対応環境: macOS / Linux / Windows (Python 3.8+)
5
5
  """
6
6
 
7
- import io, json, os, sys, stat as _stat, argparse, fnmatch, datetime, subprocess
7
+ import io, json, os, sys, stat as _stat, argparse, fnmatch, datetime, subprocess, re, ast
8
8
  from concurrent.futures import ThreadPoolExecutor
9
9
  from pathlib import Path
10
10
 
@@ -26,7 +26,7 @@ def _enable_color():
26
26
  USE_COLOR = _enable_color()
27
27
  RESET = "\033[0m"; BOLD = "\033[1m"; DIM = "\033[2m"
28
28
  BLUE = "\033[34m"; CYAN = "\033[36m"; GREEN = "\033[32m"; MAGENTA = "\033[35m"
29
- RED = "\033[31m"
29
+ RED = "\033[31m"; YELLOW = "\033[33m"
30
30
 
31
31
  def c(text, *codes):
32
32
  return ("".join(codes) + text + RESET) if USE_COLOR else text
@@ -43,7 +43,6 @@ def fmt_size(n, partial=False):
43
43
 
44
44
  def fmt_count(nd, nf, denied=False):
45
45
  sfx = "+" if denied else ""
46
- # denied=True のとき数が不明なので複数形固定
47
46
  d_word = "dir" if (nd == 1 and not denied) else "dirs"
48
47
  f_word = "file" if (nf == 1 and not denied) else "files"
49
48
  return f"{nd}{sfx} {d_word}, {nf}{sfx} {f_word}"
@@ -120,9 +119,8 @@ def copy_to_clipboard(text):
120
119
  return False
121
120
  except Exception: return False
122
121
 
123
- import re as _re
124
122
  def strip_ansi(text):
125
- return _re.sub(r'\033\[[0-9;]*[mK]', '', text)
123
+ return re.sub(r'\033\[[0-9;]*[mK]', '', text)
126
124
 
127
125
 
128
126
  # ─── 絵文字 ───────────────────────────────────────────────────
@@ -247,6 +245,537 @@ def _prefetch_sizes(root_path):
247
245
  list(ex.map(dir_size, top))
248
246
 
249
247
 
248
+ # ════════════════════════════════════════════════════════════
249
+ # AI/エージェント向け解析機能
250
+ # ════════════════════════════════════════════════════════════
251
+
252
+ _BINARY_EXTS = {
253
+ ".png",".jpg",".jpeg",".gif",".bmp",".ico",".webp",
254
+ ".mp3",".mp4",".mov",".avi",".wav",".flac",".ogg",".webm",".mkv",
255
+ ".zip",".tar",".gz",".rar",".7z",".bz2",".xz",
256
+ ".pdf",".doc",".docx",".xls",".xlsx",".ppt",".pptx",
257
+ ".exe",".dll",".so",".dylib",".bin",".o",".a",".class",".jar",
258
+ ".woff",".woff2",".ttf",".otf",".eot",
259
+ ".db",".sqlite",".sqlite3",".pyc",".pyo",".whl",
260
+ }
261
+
262
+ def _is_probably_binary(name):
263
+ return os.path.splitext(name)[1].lower() in _BINARY_EXTS
264
+
265
+
266
+ # --- トークン数概算(-T / --tokens) -------------------------
267
+ def estimate_tokens(path, actual_size=None):
268
+ """テキストファイルのトークン数を概算する。バイナリ等は None。
269
+ あくまで大まかな目安(英数字記号は約4文字/トークン、それ以外(日本語等)は約1.5文字/トークンとして概算)。
270
+ """
271
+ SAMPLE_LIMIT = 2_000_000
272
+ try:
273
+ with open(path, "rb") as f:
274
+ data = f.read(SAMPLE_LIMIT + 1)
275
+ except OSError:
276
+ return None
277
+ truncated = len(data) > SAMPLE_LIMIT
278
+ if truncated:
279
+ data = data[:SAMPLE_LIMIT]
280
+ if b"\x00" in data[:8192]:
281
+ return None # バイナリ判定
282
+ text = data.decode("utf-8", errors="ignore")
283
+ if not text:
284
+ return 0
285
+ ascii_chars = sum(1 for ch in text if ord(ch) < 128)
286
+ other_chars = len(text) - ascii_chars
287
+ tokens = ascii_chars / 4 + other_chars / 1.5
288
+ if truncated and actual_size and len(data) > 0:
289
+ tokens *= actual_size / len(data)
290
+ return max(1, round(tokens))
291
+
292
+ def fmt_tokens(n):
293
+ if n is None: return None
294
+ if n >= 1000:
295
+ s = f"{n/1000:.1f}".rstrip("0").rstrip(".")
296
+ return f"~{s}K tok"
297
+ return f"~{n} tok"
298
+
299
+
300
+ # --- git連携(-H / --git) ------------------------------------
301
+ def load_git_log(root, max_commits=2000):
302
+ """直近コミット履歴から各ファイルの最終更新コミット情報を取得する。
303
+ gitが無い/リポジトリでない場合は空dictを返す(エラーにはしない)。
304
+ パフォーマンスのため履歴は直近 max_commits 件までに限定(古いファイルは情報なしになる場合あり)。
305
+ """
306
+ try:
307
+ proc = subprocess.run(
308
+ ["git", "-C", root, "log", "-n", str(max_commits),
309
+ "--name-only", "--date=relative",
310
+ "--pretty=format:\x01%H\x02%ad\x02%an\x02%s\x03"],
311
+ capture_output=True, text=True, timeout=8, check=True,
312
+ )
313
+ except (subprocess.CalledProcessError, FileNotFoundError,
314
+ subprocess.TimeoutExpired, OSError):
315
+ return {}
316
+
317
+ file_map = {}
318
+ current = None
319
+ for raw in proc.stdout.split("\n"):
320
+ line = raw.strip("\r")
321
+ if line.startswith("\x01"):
322
+ body = line[1:]
323
+ if body.endswith("\x03"):
324
+ body = body[:-1]
325
+ parts = body.split("\x02", 3)
326
+ current = ({"hash": parts[0][:7], "date": parts[1],
327
+ "author": parts[2], "subject": parts[3]}
328
+ if len(parts) == 4 else None)
329
+ elif line.strip() and current is not None:
330
+ fp = line.strip().replace("\\", "/")
331
+ if fp not in file_map:
332
+ file_map[fp] = current
333
+ return file_map
334
+
335
+ def fmt_git(g):
336
+ if not g: return None
337
+ subj = g["subject"].strip()
338
+ if len(subj) > 30:
339
+ subj = subj[:30] + "…"
340
+ return f'"{subj}" ({g["date"]})'
341
+
342
+
343
+ # --- TODO/FIXME抽出(-K / --todo) -----------------------------
344
+ _TODO_RE = re.compile(r'\b(TODO|FIXME|HACK|XXX)\b[:\s]?(.*)', re.IGNORECASE)
345
+
346
+ def scan_todos(path, limit_bytes=2_000_000):
347
+ if _is_probably_binary(path):
348
+ return []
349
+ try:
350
+ with open(path, "rb") as f:
351
+ data = f.read(limit_bytes)
352
+ except OSError:
353
+ return []
354
+ if b"\x00" in data[:8192]:
355
+ return []
356
+ text = data.decode("utf-8", errors="ignore")
357
+ results = []
358
+ for i, line in enumerate(text.split("\n"), 1):
359
+ m = _TODO_RE.search(line)
360
+ if m:
361
+ snippet = line.strip()
362
+ if len(snippet) > 80:
363
+ snippet = snippet[:80] + "…"
364
+ results.append((i, m.group(1).upper(), snippet))
365
+ return results
366
+
367
+
368
+ # --- テスト欠落検知(-V / --missing-tests) ---------------------
369
+ _SOURCE_EXTS_FOR_TESTS = {".py", ".js", ".jsx", ".ts", ".tsx", ".go"}
370
+
371
+ def _is_test_file(name):
372
+ lower = name.lower()
373
+ stem, ext = os.path.splitext(lower)
374
+ if stem.startswith("test_") or stem.endswith("_test"):
375
+ return True
376
+ if stem.endswith(".test") or stem.endswith(".spec"):
377
+ return True
378
+ return False
379
+
380
+
381
+ # --- import/依存グラフ解析(-M / --imports) ---------------------
382
+ # Pythonは標準ライブラリの ast モジュールで正確に解析。
383
+ # JS/TS/Go/Rustは正規表現ベースの抽出+相対パス解決(best-effort)。
384
+ # 外部パッケージ(react, lodash, requests 等)はプロジェクト内ファイルに
385
+ # 解決できないため "external" 扱いとし、依存グラフには含めない。
386
+
387
+ def extract_imports_py(path):
388
+ """ASTでPythonのimport文を正確に抽出する。
389
+ 戻り値: [(module_str, level, [imported_names])] level>0 は相対import。
390
+ """
391
+ try:
392
+ with open(path, encoding="utf-8", errors="ignore") as f:
393
+ source = f.read()
394
+ tree = ast.parse(source, filename=path)
395
+ except (OSError, SyntaxError, ValueError, RecursionError):
396
+ return []
397
+ out = []
398
+ for node in ast.walk(tree):
399
+ if isinstance(node, ast.Import):
400
+ for alias in node.names:
401
+ out.append((alias.name, 0, None))
402
+ elif isinstance(node, ast.ImportFrom):
403
+ out.append((node.module or "", node.level, [a.name for a in node.names]))
404
+ return out
405
+
406
+ _JS_IMPORT_PATTERNS = [
407
+ re.compile(r'''import\s+(?:[\w*\s{},]+\s+from\s+)?['"]([^'"]+)['"]'''),
408
+ re.compile(r'''export\s+(?:[\w*\s{},]+\s+from\s+)?['"]([^'"]+)['"]'''),
409
+ re.compile(r'''require\(\s*['"]([^'"]+)['"]\s*\)'''),
410
+ re.compile(r'''import\(\s*['"]([^'"]+)['"]\s*\)'''), # 動的import()
411
+ ]
412
+
413
+ def extract_imports_js(path):
414
+ try:
415
+ with open(path, encoding="utf-8", errors="ignore") as f:
416
+ text = f.read()
417
+ except OSError:
418
+ return []
419
+ found = []
420
+ for pat in _JS_IMPORT_PATTERNS:
421
+ found.extend(pat.findall(text))
422
+ return found
423
+
424
+ _GO_IMPORT_BLOCK_RE = re.compile(r'import\s*\(([^)]*)\)', re.DOTALL)
425
+ _GO_IMPORT_LINE_RE = re.compile(r'import\s+"([^"]+)"')
426
+ _GO_IMPORT_ITEM_RE = re.compile(r'"([^"]+)"')
427
+
428
+ def extract_imports_go(path):
429
+ try:
430
+ with open(path, encoding="utf-8", errors="ignore") as f:
431
+ text = f.read()
432
+ except OSError:
433
+ return []
434
+ found = []
435
+ block = _GO_IMPORT_BLOCK_RE.search(text)
436
+ if block:
437
+ found.extend(_GO_IMPORT_ITEM_RE.findall(block.group(1)))
438
+ found.extend(_GO_IMPORT_LINE_RE.findall(text))
439
+ return found
440
+
441
+ _RS_USE_RE = re.compile(r'^\s*(?:pub\s+)?use\s+([\w:]+)', re.MULTILINE)
442
+ _RS_MOD_RE = re.compile(r'^\s*(?:pub\s+)?mod\s+(\w+)\s*;', re.MULTILINE)
443
+
444
+ def extract_imports_rs(path):
445
+ """戻り値: (use文のパスリスト, mod宣言のモジュール名リスト)"""
446
+ try:
447
+ with open(path, encoding="utf-8", errors="ignore") as f:
448
+ text = f.read()
449
+ except OSError:
450
+ return [], []
451
+ return _RS_USE_RE.findall(text), _RS_MOD_RE.findall(text)
452
+
453
+
454
+ def _py_module_key(relpath):
455
+ """'pkg/sub/mod.py' -> 'pkg.sub.mod'、'pkg/sub/__init__.py' -> 'pkg.sub'"""
456
+ parts = relpath.replace("\\", "/").split("/")
457
+ if parts[-1] == "__init__.py":
458
+ parts = parts[:-1]
459
+ elif parts[-1].endswith(".py"):
460
+ parts[-1] = parts[-1][:-3]
461
+ return ".".join(parts)
462
+
463
+ def _resolve_relative_path(base_dir, target, project_files):
464
+ """JS/TS の相対import('./foo'、'../bar')をプロジェクト内ファイルに解決する。"""
465
+ candidate = os.path.normpath(os.path.join(base_dir, target)).replace("\\", "/")
466
+ for suffix in ("", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
467
+ "/index.js", "/index.ts", "/index.jsx", "/index.tsx"):
468
+ cand = candidate + suffix
469
+ if cand in project_files:
470
+ return cand
471
+ return None
472
+
473
+ def _resolve_rust_crate_path(use_path, project_files):
474
+ """'crate::foo::bar::Baz' をプロジェクト内ファイルに解決する(src/foo/bar.rs 等)。
475
+ self:: / super:: および外部crateは簡易実装のため非対応(external扱い)。
476
+ """
477
+ if not use_path.startswith("crate::"):
478
+ return None
479
+ body = use_path.split("::", 1)[1]
480
+ segments = [s for s in body.split("::") if s and s not in ("self", "*")]
481
+ for cut in (len(segments), len(segments) - 1):
482
+ if cut <= 0: continue
483
+ path_part = "/".join(segments[:cut])
484
+ for cand in (f"src/{path_part}.rs", f"src/{path_part}/mod.rs"):
485
+ if cand in project_files:
486
+ return cand
487
+ return None
488
+
489
+
490
+ # --- エントリーポイント検出(-N / --entry) ----------------------
491
+ _ENTRY_NAMES_LOWER = {
492
+ "main.py", "__main__.py", "app.py", "server.py", "manage.py", "wsgi.py", "asgi.py",
493
+ "index.js", "index.ts", "index.mjs", "index.cjs",
494
+ "main.js", "main.ts", "server.js", "server.ts", "app.js", "app.ts",
495
+ "main.go", "main.rs",
496
+ "makefile", "dockerfile", "docker-compose.yml", "docker-compose.yaml",
497
+ }
498
+
499
+
500
+ def build_project_index(root, cfg):
501
+ """テスト欠落検知・エントリーポイント検出・import依存グラフのため、
502
+ プロジェクト全体を一度だけスキャンする。
503
+ .gitignore は -G 指定時のみ尊重する。-G なしで巨大な node_modules 等があると遅くなる場合がある。
504
+ 戻り値: (untested_relpaths, entry_relpaths, imports_map, imported_by_map, external_map)
505
+ """
506
+ all_names = set()
507
+ all_relpaths = set()
508
+ source_files = []
509
+ entry_set = set()
510
+ py_module_map = {}
511
+ go_module_name = [None] # nonlocalの代わりにリストで包む
512
+
513
+ def walk(path, active_pats):
514
+ pats = _extend_pats(active_pats, path, cfg) if cfg.use_gitignore else active_pats
515
+ try:
516
+ entries = list(os.scandir(path))
517
+ except OSError:
518
+ return
519
+ entries = [e for e in entries if cfg.show_all or not e.name.startswith(".")]
520
+ if pats:
521
+ entries = [e for e in entries
522
+ if not is_ignored(e.name, os.path.relpath(e.path, root),
523
+ e.is_dir(follow_symlinks=False), pats)]
524
+ for e in entries:
525
+ if e.is_dir(follow_symlinks=False):
526
+ walk(e.path, pats)
527
+ continue
528
+ relpath = os.path.relpath(e.path, root).replace("\\", "/")
529
+ stem, ext = os.path.splitext(e.name)
530
+ all_names.add(e.name.lower())
531
+ all_relpaths.add(relpath)
532
+ if ext.lower() in _SOURCE_EXTS_FOR_TESTS and not _is_test_file(e.name):
533
+ source_files.append((relpath, stem, ext.lower()))
534
+ if e.name.lower() in _ENTRY_NAMES_LOWER:
535
+ entry_set.add(relpath)
536
+ if ext.lower() == ".py":
537
+ py_module_map[_py_module_key(relpath)] = relpath
538
+ if e.name == "go.mod":
539
+ try:
540
+ with open(e.path, encoding="utf-8", errors="ignore") as f:
541
+ for line in f:
542
+ line = line.strip()
543
+ if line.startswith("module "):
544
+ go_module_name[0] = line.split(None, 1)[1].strip()
545
+ break
546
+ except OSError:
547
+ pass
548
+ if e.name == "package.json":
549
+ try:
550
+ with open(e.path, encoding="utf-8") as f:
551
+ pkg = json.load(f)
552
+ base_dir = os.path.dirname(relpath)
553
+ main_field = pkg.get("main")
554
+ if isinstance(main_field, str):
555
+ entry_set.add(os.path.normpath(
556
+ os.path.join(base_dir, main_field)).replace("\\", "/"))
557
+ bin_field = pkg.get("bin")
558
+ if isinstance(bin_field, str):
559
+ entry_set.add(os.path.normpath(
560
+ os.path.join(base_dir, bin_field)).replace("\\", "/"))
561
+ elif isinstance(bin_field, dict):
562
+ for v in bin_field.values():
563
+ if isinstance(v, str):
564
+ entry_set.add(os.path.normpath(
565
+ os.path.join(base_dir, v)).replace("\\", "/"))
566
+ except (OSError, json.JSONDecodeError, AttributeError, ValueError):
567
+ pass
568
+
569
+ walk(root, [])
570
+
571
+ untested = set()
572
+ for relpath, stem, ext in source_files:
573
+ candidates = {f"test_{stem}{ext}", f"{stem}_test{ext}",
574
+ f"{stem}.test{ext}", f"{stem}.spec{ext}"}
575
+ if not (candidates & all_names):
576
+ untested.add(relpath)
577
+
578
+ imports_map, imported_by_acc, external_map = {}, {}, {}
579
+ if cfg.show_imports:
580
+ for relpath in sorted(all_relpaths):
581
+ ext = os.path.splitext(relpath)[1].lower()
582
+ full_path = os.path.join(root, relpath)
583
+ base_dir = os.path.dirname(relpath)
584
+ local_targets, external_raw = set(), []
585
+
586
+ if ext == ".py":
587
+ for mod, level, names in extract_imports_py(full_path):
588
+ if level and level > 0:
589
+ pkg_parts = base_dir.split("/") if base_dir else []
590
+ up = level - 1
591
+ pkg_parts = pkg_parts[:-up] if (up and up <= len(pkg_parts)) else \
592
+ (pkg_parts if not up else [])
593
+ target_key = ".".join(pkg_parts + ([mod] if mod else []))
594
+ resolved = None
595
+ # 'from .pkg import sub' は sub がサブモジュールの可能性があるため、
596
+ # まず names を「パッケージ内のサブモジュール」として解決を試みる
597
+ # (例: from . import app_helpers → app_helpers.py)
598
+ if names:
599
+ for nm in names:
600
+ cand_key = f"{target_key}.{nm}" if target_key else nm
601
+ resolved = py_module_map.get(cand_key)
602
+ if resolved: break
603
+ # 解決できなければ、import先自体がモジュールである通常パターンにフォールバック
604
+ # (例: from .utils import helper_function → utils.py)
605
+ if not resolved:
606
+ resolved = py_module_map.get(target_key)
607
+ if resolved and resolved != relpath:
608
+ local_targets.add(resolved)
609
+ else:
610
+ external_raw.append("." * level + (mod or ""))
611
+ else:
612
+ resolved = py_module_map.get(mod)
613
+ if resolved and resolved != relpath:
614
+ local_targets.add(resolved)
615
+ else:
616
+ external_raw.append(mod)
617
+
618
+ elif ext in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"):
619
+ for spec in extract_imports_js(full_path):
620
+ if spec.startswith(".") or spec.startswith("/"):
621
+ resolved = _resolve_relative_path(base_dir, spec, all_relpaths)
622
+ if resolved and resolved != relpath:
623
+ local_targets.add(resolved)
624
+ else:
625
+ external_raw.append(spec)
626
+ else:
627
+ external_raw.append(spec)
628
+
629
+ elif ext == ".go":
630
+ mod_name = go_module_name[0]
631
+ for spec in extract_imports_go(full_path):
632
+ if mod_name and spec.startswith(mod_name):
633
+ sub = spec[len(mod_name):].lstrip("/")
634
+ candidates = [r for r in all_relpaths if r.endswith(".go") and
635
+ (r.startswith(sub + "/") or os.path.dirname(r) == sub)]
636
+ if candidates:
637
+ for cand in candidates:
638
+ if cand != relpath:
639
+ local_targets.add(cand)
640
+ else:
641
+ external_raw.append(spec)
642
+ else:
643
+ external_raw.append(spec)
644
+
645
+ elif ext == ".rs":
646
+ uses, mods = extract_imports_rs(full_path)
647
+ for m in mods:
648
+ for cand in (f"{base_dir}/{m}.rs" if base_dir else f"{m}.rs",
649
+ f"{base_dir}/{m}/mod.rs" if base_dir else f"{m}/mod.rs"):
650
+ if cand in all_relpaths:
651
+ local_targets.add(cand)
652
+ for u in uses:
653
+ resolved = _resolve_rust_crate_path(u, all_relpaths)
654
+ if resolved and resolved != relpath:
655
+ local_targets.add(resolved)
656
+ else:
657
+ external_raw.append(u)
658
+
659
+ if local_targets:
660
+ imports_map[relpath] = sorted(local_targets)
661
+ for t in local_targets:
662
+ imported_by_acc.setdefault(t, set()).add(relpath)
663
+ if external_raw:
664
+ # 重複除去しつつ最大10件まで
665
+ seen = []
666
+ for x in external_raw:
667
+ if x and x not in seen:
668
+ seen.append(x)
669
+ external_map[relpath] = seen[:10]
670
+
671
+ imported_by_map = {k: sorted(v) for k, v in imported_by_acc.items()}
672
+ return untested, entry_set, imports_map, imported_by_map, external_map
673
+
674
+
675
+ # --- シンボルアウトライン(-O / --outline) ----------------------
676
+ # 正規表現ベースの簡易抽出。AST解析ではないため、デコレータが複雑な場合や
677
+ # 複数行にまたがる関数シグネチャ等は取得漏れすることがある(best-effort)。
678
+ _JS_TS_PATTERNS = [
679
+ (re.compile(r'^\s*(?:export\s+)?(?:default\s+)?class\s+(\w+)'), "class"),
680
+ (re.compile(r'^\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?function\s*\*?\s+(\w+)\s*\('), "func"),
681
+ (re.compile(r'^\s*export\s+(?:default\s+)?(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\('), "func"),
682
+ (re.compile(r'^\s*(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?\(.*\)\s*=>'), "func"),
683
+ ]
684
+ _OUTLINE_PATTERNS = {
685
+ ".py": [
686
+ (re.compile(r'^(\s*)class\s+(\w+)'), "class"),
687
+ (re.compile(r'^(\s*)(?:async\s+)?def\s+(\w+)\s*\('), "def"),
688
+ ],
689
+ ".go": [
690
+ (re.compile(r'^func\s+(?:\([^)]*\)\s+)?(\w+)\s*\('), "func"),
691
+ (re.compile(r'^type\s+(\w+)\s+struct'), "struct"),
692
+ (re.compile(r'^type\s+(\w+)\s+interface'), "interface"),
693
+ ],
694
+ ".rs": [
695
+ (re.compile(r'^\s*(?:pub(?:\([^)]*\))?\s+)?(?:async\s+)?fn\s+(\w+)'), "fn"),
696
+ (re.compile(r'^\s*(?:pub(?:\([^)]*\))?\s+)?struct\s+(\w+)'), "struct"),
697
+ (re.compile(r'^\s*(?:pub(?:\([^)]*\))?\s+)?enum\s+(\w+)'), "enum"),
698
+ (re.compile(r'^\s*(?:pub(?:\([^)]*\))?\s+)?trait\s+(\w+)'), "trait"),
699
+ ],
700
+ }
701
+ for _e in (".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs"):
702
+ _OUTLINE_PATTERNS[_e] = _JS_TS_PATTERNS
703
+
704
+ def extract_outline(path, ext, limit_lines=4000):
705
+ """対応言語(Python/JS/TS/Go/Rust)の関数・クラス名を正規表現で簡易抽出する。
706
+ 対応外の拡張子は None を返す(「対応していない」ことを明示するため空リストとは区別)。
707
+ """
708
+ patterns = _OUTLINE_PATTERNS.get(ext)
709
+ if not patterns:
710
+ return None
711
+ try:
712
+ with open(path, encoding="utf-8", errors="ignore") as f:
713
+ lines = []
714
+ for i, line in enumerate(f):
715
+ if i >= limit_lines:
716
+ break
717
+ lines.append(line)
718
+ except OSError:
719
+ return []
720
+ out = []
721
+ for line in lines:
722
+ for pat, kind in patterns:
723
+ m = pat.match(line)
724
+ if m:
725
+ out.append((kind, m.group(m.lastindex)))
726
+ break
727
+ return out
728
+
729
+ def fmt_outline(outline, limit=5):
730
+ if not outline:
731
+ return None
732
+ items = [f"{kind} {name}" for kind, name in outline]
733
+ shown = items[:limit]
734
+ s = ", ".join(shown)
735
+ if len(items) > limit:
736
+ s += f", +{len(items)-limit}"
737
+ return s
738
+
739
+
740
+ def _file_extras(entry, relpath, cfg):
741
+ """有効になっているAI解析フラグに応じて、ファイル単位の追加情報を計算する。"""
742
+ extras = {}
743
+ ext = os.path.splitext(entry.name)[1].lower()
744
+
745
+ if cfg.show_tokens:
746
+ if _is_probably_binary(entry.name):
747
+ extras["tokens"] = None
748
+ else:
749
+ try: sz = entry.stat(follow_symlinks=True).st_size
750
+ except OSError: sz = None
751
+ extras["tokens"] = estimate_tokens(entry.path, actual_size=sz)
752
+
753
+ if cfg.show_git:
754
+ extras["git"] = cfg.git_map.get(relpath)
755
+
756
+ if cfg.show_todo:
757
+ extras["todos"] = scan_todos(entry.path)
758
+
759
+ if cfg.show_entry:
760
+ extras["is_entry"] = relpath in cfg.entry_set
761
+
762
+ if cfg.show_tests:
763
+ extras["no_test"] = relpath in cfg.untested_set
764
+
765
+ if cfg.show_outline:
766
+ extras["outline"] = extract_outline(entry.path, ext)
767
+
768
+ if cfg.show_imports:
769
+ extras["imports"] = cfg.imports_map.get(relpath, [])
770
+ extras["imported_by"] = cfg.imported_by_map.get(relpath, [])
771
+ extras["external_imports"] = cfg.external_map.get(relpath, [])
772
+
773
+ return extras
774
+
775
+
776
+ # ════════════════════════════════════════════════════════════
777
+
778
+
250
779
  # ─── 設定クラス ───────────────────────────────────────────────
251
780
  class Cfg:
252
781
  def __init__(self, args, root):
@@ -275,6 +804,25 @@ class Cfg:
275
804
  self.reverse = args.reverse
276
805
  self.files_first = args.filesfirst
277
806
 
807
+ # AI/エージェント向け解析フラグ
808
+ self.show_tokens = args.tokens # -T
809
+ self.show_git = args.git # -H
810
+ self.show_todo = args.todo # -K
811
+ self.show_tests = args.tests # -V
812
+ self.show_entry = args.entry # -N
813
+ self.show_outline = args.outline # -O
814
+ self.show_imports = args.imports # -M
815
+ self.has_extras = any([self.show_tokens, self.show_git, self.show_todo,
816
+ self.show_tests, self.show_entry, self.show_outline,
817
+ self.show_imports])
818
+ # main() 側で必要に応じて埋める
819
+ self.git_map = {}
820
+ self.untested_set = set()
821
+ self.entry_set = set()
822
+ self.imports_map = {}
823
+ self.imported_by_map = {}
824
+ self.external_map = {}
825
+
278
826
 
279
827
  # ─── 共通フィルタリング ───────────────────────────────────────
280
828
  def _filter(path, cfg, active_pats):
@@ -298,7 +846,6 @@ def _filter(path, cfg, active_pats):
298
846
  and e.is_dir(follow_symlinks=True)]
299
847
  dirs = dirs + sym_dirs
300
848
 
301
- # -d (dirs only): ファイルを非表示
302
849
  if cfg.dirs_only:
303
850
  files = []
304
851
  else:
@@ -336,7 +883,7 @@ def _has_content(path, depth, cfg, active_pats):
336
883
  if cfg.max_depth is not None and depth >= cfg.max_depth: return False
337
884
  pats = _extend_pats(active_pats, path, cfg)
338
885
  dirs, files = _filter(path, cfg, pats)
339
- if dirs is None: return False # アクセス拒否は空とみなす
886
+ if dirs is None: return False
340
887
  if files: return True
341
888
  for d in dirs:
342
889
  if _has_content(d.path, depth + 1, cfg, pats): return True
@@ -345,7 +892,6 @@ def _has_content(path, depth, cfg, active_pats):
345
892
 
346
893
  # ─── ソートヘルパー ───────────────────────────────────────────
347
894
  def _sort_entries(dirs, files, cfg):
348
- """設定に基づいてdirs/filesをソートする(-t/-c/-S/-r対応)。"""
349
895
  def emtime(e):
350
896
  try: return e.stat(follow_symlinks=True).st_mtime
351
897
  except OSError: return 0
@@ -357,16 +903,16 @@ def _sort_entries(dirs, files, cfg):
357
903
  except OSError: return 0
358
904
 
359
905
  rev = cfg.reverse
360
- if cfg.sort_mtime: # -t: 更新日時順(新しい順)
906
+ if cfg.sort_mtime:
361
907
  dirs.sort(key=emtime, reverse=not rev)
362
908
  files.sort(key=emtime, reverse=not rev)
363
- elif cfg.sort_ctime: # -c: ctime順(新しい順)
909
+ elif cfg.sort_ctime:
364
910
  dirs.sort(key=ectime, reverse=not rev)
365
911
  files.sort(key=ectime, reverse=not rev)
366
- elif cfg.by_size: # -S: サイズ順(大きい順)
912
+ elif cfg.by_size:
367
913
  dirs.sort(key=lambda e: dir_size(e.path)[0], reverse=not rev)
368
914
  files.sort(key=esz, reverse=not rev)
369
- else: # デフォルト: アルファベット順
915
+ else:
370
916
  dirs.sort(key=lambda e: e.name.casefold(), reverse=rev)
371
917
  files.sort(key=lambda e: e.name.casefold(), reverse=rev)
372
918
 
@@ -389,7 +935,7 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
389
935
 
390
936
  cur_pats = _extend_pats(active_pats, path, cfg)
391
937
  dirs, files = _filter(path, cfg, cur_pats)
392
- if dirs is None: # アクセス拒否
938
+ if dirs is None:
393
939
  print(f"{prefix}{LAST}{c('[アクセス拒否]', BOLD, RED)}")
394
940
  return
395
941
 
@@ -450,21 +996,62 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
450
996
  stats["extensions"][ext or "(no ext)"] = \
451
997
  stats["extensions"].get(ext or "(no ext)", 0) + 1
452
998
 
999
+ rel = os.path.relpath(entry.path, cfg.root).replace("\\", "/")
1000
+ extras = _file_extras(entry, rel, cfg) if cfg.has_extras else {}
1001
+
1002
+ entry_mark = ""
1003
+ if extras.get("is_entry"):
1004
+ entry_mark = "🎯 " if cfg.show_emoji else "* "
1005
+ stats["entries"] += 1
1006
+
453
1007
  emoji = (get_emoji(entry.name) + " ") if cfg.show_emoji else ""
454
- parts = [fmt_size(sz)] # ファイルサイズは stat() で正確に取れるので + なし
1008
+ parts = [fmt_size(sz)]
455
1009
  if cfg.show_date:
456
1010
  mt = emtime(entry)
457
1011
  if mt: parts.append(fmt_date(mt))
1012
+
1013
+ if cfg.show_tokens and extras.get("tokens") is not None:
1014
+ parts.append(fmt_tokens(extras["tokens"]))
1015
+ stats["tokens"] += extras["tokens"]
1016
+
1017
+ if cfg.show_git and extras.get("git"):
1018
+ parts.append(fmt_git(extras["git"]))
1019
+
1020
+ if cfg.show_todo and extras.get("todos"):
1021
+ n_todo = len(extras["todos"])
1022
+ parts.append(f"TODO×{n_todo}")
1023
+ stats["todo_total"] += n_todo
1024
+ for item in extras["todos"][:3]:
1025
+ if len(stats["todo_samples"]) < 20:
1026
+ stats["todo_samples"].append((rel, *item))
1027
+
1028
+ if cfg.show_tests and extras.get("no_test"):
1029
+ parts.append("テスト無し")
1030
+ stats["no_test"] += 1
1031
+
1032
+ if cfg.show_outline and extras.get("outline"):
1033
+ ostr = fmt_outline(extras["outline"])
1034
+ if ostr: parts.append(ostr)
1035
+
1036
+ if cfg.show_imports:
1037
+ imp_n = len(extras.get("imports") or [])
1038
+ used_n = len(extras.get("imported_by") or [])
1039
+ if imp_n: parts.append(f"imports×{imp_n}")
1040
+ if used_n: parts.append(f"used-by×{used_n}")
1041
+
458
1042
  bar = (" " + fmt_bar(sz, cur_dir_size)) if cfg.show_bar and cur_dir_size else ""
459
1043
 
460
- name = c(f"{emoji}{display}{sym_target}",
1044
+ name = c(f"{entry_mark}{display}{sym_target}",
461
1045
  MAGENTA if entry.is_symlink() else GREEN)
462
1046
  meta = c(f"({', '.join(parts)}){bar}", DIM)
463
1047
  print(f"{prefix}{branch}{perm_prefix}{name} {meta}")
464
1048
 
465
1049
 
466
1050
  # ─── JSON出力 ─────────────────────────────────────────────────
467
- def build_json_tree(path, depth, cfg, active_pats):
1051
+ def build_json_tree(path, depth, cfg, active_pats, stats=None):
1052
+ if stats is None:
1053
+ stats = {"tokens": 0, "todo_total": 0, "todo_samples": [], "no_test": 0, "entries": 0}
1054
+
468
1055
  cur_pats = _extend_pats(active_pats, path, cfg)
469
1056
  dirs, files = _filter(path, cfg, cur_pats)
470
1057
  denied = dirs is None
@@ -483,15 +1070,47 @@ def build_json_tree(path, depth, cfg, active_pats):
483
1070
  combined = (files + dirs) if cfg.files_first else (dirs + files)
484
1071
  for entry in combined:
485
1072
  if entry.is_dir(follow_symlinks=False):
486
- children.append(build_json_tree(entry.path, depth + 1, cfg, cur_pats))
1073
+ children.append(build_json_tree(entry.path, depth + 1, cfg, cur_pats, stats))
487
1074
  else:
488
1075
  f_sz = esz(entry)
489
- children.append({
1076
+ rel = os.path.relpath(entry.path, cfg.root).replace("\\", "/")
1077
+ extras = _file_extras(entry, rel, cfg) if cfg.has_extras else {}
1078
+
1079
+ file_obj = {
490
1080
  "name": entry.name, "type": "file",
491
1081
  "size": f_sz, "size_human": fmt_size(f_sz),
492
1082
  "ext": os.path.splitext(entry.name)[1].lower(),
493
- "path": os.path.relpath(entry.path, cfg.root),
494
- })
1083
+ "path": rel,
1084
+ }
1085
+ if cfg.show_tokens:
1086
+ file_obj["tokens"] = extras.get("tokens")
1087
+ if extras.get("tokens") is not None:
1088
+ stats["tokens"] += extras["tokens"]
1089
+ if cfg.show_git:
1090
+ file_obj["git"] = extras.get("git")
1091
+ if cfg.show_todo:
1092
+ todos = extras.get("todos") or []
1093
+ file_obj["todos"] = [{"line": ln, "kind": k, "text": s} for ln, k, s in todos]
1094
+ stats["todo_total"] += len(todos)
1095
+ for item in todos[:3]:
1096
+ if len(stats["todo_samples"]) < 20:
1097
+ stats["todo_samples"].append((rel, *item))
1098
+ if cfg.show_tests:
1099
+ file_obj["has_test"] = not extras.get("no_test", False)
1100
+ if extras.get("no_test"): stats["no_test"] += 1
1101
+ if cfg.show_entry:
1102
+ file_obj["is_entry"] = bool(extras.get("is_entry"))
1103
+ if extras.get("is_entry"): stats["entries"] += 1
1104
+ if cfg.show_outline:
1105
+ outline = extras.get("outline")
1106
+ file_obj["outline"] = ([{"kind": k, "name": n} for k, n in outline]
1107
+ if outline else outline) # None=対応外言語
1108
+ if cfg.show_imports:
1109
+ file_obj["imports"] = extras.get("imports") or []
1110
+ file_obj["imported_by"] = extras.get("imported_by") or []
1111
+ file_obj["external_imports"] = extras.get("external_imports") or []
1112
+
1113
+ children.append(file_obj)
495
1114
 
496
1115
  name = os.path.basename(path) or path
497
1116
  return {
@@ -537,10 +1156,21 @@ def generate_html(root_path, cfg, active_pats):
537
1156
  if entry.is_symlink():
538
1157
  try: sym = f' → {os.readlink(entry.path)}'
539
1158
  except OSError: sym = ' →'
1159
+
1160
+ rel = os.path.relpath(entry.path, cfg.root).replace("\\", "/")
1161
+ extras = _file_extras(entry, rel, cfg) if cfg.has_extras else {}
1162
+ badges = ""
1163
+ if extras.get("is_entry"):
1164
+ badges += '<span class="badge entry">entry</span>'
1165
+ if extras.get("no_test"):
1166
+ badges += '<span class="badge notest">no test</span>'
1167
+ if extras.get("todos"):
1168
+ badges += f'<span class="badge todo">TODO×{len(extras["todos"])}</span>'
1169
+
540
1170
  ch += (f'<div class="item file">'
541
1171
  f'<span class="emoji">{get_emoji(entry.name)}</span>'
542
1172
  f'<span class="fname"> {entry.name}{sym}</span>'
543
- f'<span class="sz"> {fmt_size(f_sz)}</span></div>\n')
1173
+ f'<span class="sz"> {fmt_size(f_sz)}</span>{badges}</div>\n')
544
1174
 
545
1175
  nd, nf = len(dirs), len(files)
546
1176
  opened = " open" if depth == 0 else ""
@@ -573,6 +1203,11 @@ summary:hover{{background:rgba(255,255,255,.06)}}
573
1203
  .item:hover{{background:rgba(255,255,255,.05);border-radius:4px}}
574
1204
  .fname{{color:#a6e3a1}}.sz{{color:#585b70;font-size:12px}}
575
1205
  .emoji{{width:1.6em;display:inline-block}}.hidden{{display:none!important}}
1206
+ .badge{{display:inline-block;margin-left:6px;padding:0 6px;border-radius:8px;
1207
+ font-size:10px;vertical-align:middle}}
1208
+ .badge.entry{{background:#89b4fa;color:#1e1e2e}}
1209
+ .badge.notest{{background:#f9e2af;color:#1e1e2e}}
1210
+ .badge.todo{{background:#f38ba8;color:#1e1e2e}}
576
1211
  </style></head><body>
577
1212
  <h1>🌳 dirlens — {root_name}</h1>
578
1213
  <input id="q" type="text" placeholder="ファイル名で検索…" oninput="search(this.value)">
@@ -580,18 +1215,13 @@ summary:hover{{background:rgba(255,255,255,.06)}}
580
1215
  <script>
581
1216
  function search(q){{
582
1217
  q=q.toLowerCase().trim();
583
- // まず全要素を表示に戻す
584
1218
  document.querySelectorAll('.hidden').forEach(el=>el.classList.remove('hidden'));
585
1219
  if(!q) return;
586
- // 全ディレクトリを展開
587
1220
  document.querySelectorAll('details').forEach(d=>d.open=true);
588
- // マッチしないファイルを非表示
589
1221
  document.querySelectorAll('.file').forEach(el=>{{
590
1222
  const n=el.querySelector('.fname')?.textContent.toLowerCase()||'';
591
1223
  if(!n.includes(q)) el.classList.add('hidden');
592
1224
  }});
593
- // visible なファイルを1つも持たないディレクトリを非表示
594
- // (空ディレクトリ・検索に引っかからないディレクトリ両方を処理)
595
1225
  document.querySelectorAll('#tree details').forEach(detail=>{{
596
1226
  const hasVisible=[...detail.querySelectorAll('.file')]
597
1227
  .some(f=>!f.classList.contains('hidden'));
@@ -612,15 +1242,18 @@ def main():
612
1242
  formatter_class=argparse.RawDescriptionHelpFormatter,
613
1243
  epilog=(
614
1244
  "使用例:\n"
615
- " dirlens --ai AIチャット貼り付け用(推奨)\n"
616
- " dirlens -d ディレクトリのみ表示(tree -d 互換)\n"
617
- " dirlens -L 2 深さ 2 まで表示(tree -L 互換)\n"
618
- " dirlens -t 更新日時順にソート(tree -t 互換)\n"
619
- " dirlens -G --prune gitignore除外 + 空枝を剪定\n"
620
- " dirlens -p -u -g パーミッション・ユーザー・グループ表示\n"
621
- " dirlens -e py .pyのみ表示(旧 -t)\n"
622
- " dirlens -S サイズ順ソート(旧 -s)\n"
623
- " dirlens -C クリップボードにコピー(旧 -c)\n"
1245
+ " dirlens --ai AIチャット貼り付け用(人間がコピペする想定)\n"
1246
+ " dirlens --agent エージェント向け解析(クリップボードは使わない)\n"
1247
+ " dirlens -d ディレクトリのみ表示(tree -d 互換)\n"
1248
+ " dirlens -L 2 深さ 2 まで表示(tree -L 互換)\n"
1249
+ " dirlens -G --prune gitignore除外 + 空枝を剪定\n"
1250
+ " dirlens -T ファイルごとの推定トークン数を表示\n"
1251
+ " dirlens -H 最終コミット情報を表示(要git)\n"
1252
+ " dirlens -K TODO/FIXME/HACKを抽出\n"
1253
+ " dirlens -V テストが無いソースファイルを表示\n"
1254
+ " dirlens -N エントリーポイントらしきファイルをマーク\n"
1255
+ " dirlens -O 関数・クラスの簡易アウトラインを表示\n"
1256
+ " dirlens -M ローカルなimport/依存関係を解析\n"
624
1257
  " dirlens --no-color > dirlens.txt ファイルに書き出す"
625
1258
  ),
626
1259
  )
@@ -667,6 +1300,23 @@ def main():
667
1300
  ap.add_argument("-I", dest="exclude_tree", metavar="PATTERN", action="append",
668
1301
  help="除外パターン(tree -I 互換)")
669
1302
 
1303
+ # ── AI/エージェント向け解析フラグ ─────────────────────────
1304
+ ap.add_argument("-T", "--tokens", action="store_true",
1305
+ help="ファイルごとの推定トークン数を表示(概算)")
1306
+ ap.add_argument("-H", "--git", action="store_true",
1307
+ help="最終コミット情報を表示(要git、直近2000コミットまで走査)")
1308
+ ap.add_argument("-K", "--todo", action="store_true",
1309
+ help="TODO/FIXME/HACK/XXXコメントを抽出")
1310
+ ap.add_argument("-V", "--missing-tests", action="store_true", dest="tests",
1311
+ help="対応するテストファイルが見つからないソースファイルを表示")
1312
+ ap.add_argument("-N", "--entry", action="store_true",
1313
+ help="エントリーポイントらしきファイルを検出してマーク")
1314
+ ap.add_argument("-O", "--outline", action="store_true",
1315
+ help="関数・クラスの簡易アウトラインを表示(正規表現ベース・対応言語限定)")
1316
+ ap.add_argument("-M", "--imports", action="store_true",
1317
+ help="ローカルなimport/依存関係を解析して表示(Python/JS/TS/Go/Rust対応、"
1318
+ "正確さは言語による。外部パッケージは対象外)")
1319
+
670
1320
  # ── dirlens独自オプション ─────────────────────────────────
671
1321
  ap.add_argument("path", nargs="?", default=".")
672
1322
  ap.add_argument("--depth", type=int, metavar="N",
@@ -685,7 +1335,9 @@ def main():
685
1335
  ap.add_argument("--prune", action="store_true")
686
1336
  ap.add_argument("--filesfirst", action="store_true")
687
1337
  ap.add_argument("--ai", action="store_true",
688
- help="-G --date -m -C のショートカット")
1338
+ help="-G --date -m -C のショートカット(人間がAIチャットに貼り付ける用)")
1339
+ ap.add_argument("--agent", action="store_true",
1340
+ help="-G -T -H -K -V -N -O のショートカット(エージェント向け解析、クリップボードは使わない)")
689
1341
  args = ap.parse_args()
690
1342
 
691
1343
  # ── エイリアスのマージ ────────────────────────────────────
@@ -696,13 +1348,25 @@ def main():
696
1348
  if args.no_color_tree: args.no_color = True
697
1349
  if args.json_tree: args.json = True
698
1350
 
699
- # --ai: -G --date -m -C のショートカット
1351
+ # --ai: 人間がAIチャットに貼り付けるためのショートカット(クリップボードを使う)
700
1352
  if args.ai:
701
1353
  args.gitignore = True
702
1354
  args.date = True
703
1355
  args.markdown = True
704
1356
  args.copy = True
705
1357
 
1358
+ # --agent: エージェントが自律実行しても安全なショートカット(クリップボードは使わない)
1359
+ if args.agent:
1360
+ args.gitignore = True
1361
+ args.date = True
1362
+ args.tokens = True
1363
+ args.git = True
1364
+ args.todo = True
1365
+ args.tests = True
1366
+ args.entry = True
1367
+ args.outline = True
1368
+ args.imports = True
1369
+
706
1370
  if args.no_color or args.markdown or args.json:
707
1371
  USE_COLOR = False
708
1372
 
@@ -714,12 +1378,33 @@ def main():
714
1378
 
715
1379
  cfg = Cfg(args, str(target))
716
1380
  active_pats = load_gitignore(str(target)) if args.gitignore else []
1381
+
1382
+ if cfg.show_tests or cfg.show_entry or cfg.show_imports:
1383
+ (cfg.untested_set, cfg.entry_set,
1384
+ cfg.imports_map, cfg.imported_by_map, cfg.external_map) = build_project_index(str(target), cfg)
1385
+ if cfg.show_git:
1386
+ cfg.git_map = load_git_log(str(target))
1387
+
717
1388
  _prefetch_sizes(str(target))
718
1389
 
719
1390
  # ── JSON ─────────────────────────────────────────────────
720
1391
  if args.json:
721
- print(json.dumps(build_json_tree(str(target), 0, cfg, active_pats),
722
- ensure_ascii=False, indent=2)); return
1392
+ stats = {"tokens": 0, "todo_total": 0, "todo_samples": [], "no_test": 0, "entries": 0}
1393
+ tree = build_json_tree(str(target), 0, cfg, active_pats, stats)
1394
+ if cfg.has_extras:
1395
+ most_depended = None
1396
+ if cfg.show_imports and cfg.imported_by_map:
1397
+ top = sorted(cfg.imported_by_map.items(), key=lambda kv: -len(kv[1]))[:10]
1398
+ most_depended = [{"path": p, "used_by_count": len(v)} for p, v in top]
1399
+ tree["project_summary"] = {
1400
+ "estimated_tokens": stats["tokens"] if cfg.show_tokens else None,
1401
+ "todo_count": stats["todo_total"] if cfg.show_todo else None,
1402
+ "missing_tests_count": stats["no_test"] if cfg.show_tests else None,
1403
+ "entry_points_count": stats["entries"] if cfg.show_entry else None,
1404
+ "git_available": bool(cfg.git_map) if cfg.show_git else None,
1405
+ "most_depended_on": most_depended,
1406
+ }
1407
+ print(json.dumps(tree, ensure_ascii=False, indent=2)); return
723
1408
 
724
1409
  # ── HTML ─────────────────────────────────────────────────
725
1410
  if args.html:
@@ -746,7 +1431,8 @@ def main():
746
1431
  print(f"{c(root_emoji + root_label + '/', BOLD, BLUE)} "
747
1432
  f"{c('(' + ', '.join(root_parts) + ')', DIM)}")
748
1433
 
749
- stats = {"files": 0, "dirs": 0, "extensions": {}}
1434
+ stats = {"files": 0, "dirs": 0, "extensions": {},
1435
+ "tokens": 0, "todo_total": 0, "todo_samples": [], "no_test": 0, "entries": 0}
750
1436
  render(str(target), "", 0, cfg, stats, active_pats)
751
1437
 
752
1438
  print()
@@ -767,6 +1453,35 @@ def main():
767
1453
  exts = sorted(stats["extensions"].items(), key=lambda x: -x[1])
768
1454
  print(c(" " + " ".join(f"{e} ×{n}" for e, n in exts[:8]), DIM))
769
1455
 
1456
+ if cfg.show_tokens:
1457
+ print(c(f" 推定トークン数: {fmt_tokens(stats['tokens'])}", DIM))
1458
+
1459
+ if cfg.show_todo:
1460
+ if stats["todo_total"]:
1461
+ print(c(f" TODO/FIXME等: {stats['todo_total']}件", DIM))
1462
+ for rel, ln, kind, snippet in stats["todo_samples"][:8]:
1463
+ print(c(f" {rel}:{ln} [{kind}] {snippet}", DIM))
1464
+ if stats["todo_total"] > min(len(stats["todo_samples"]), 8):
1465
+ rest = stats["todo_total"] - min(len(stats["todo_samples"]), 8)
1466
+ print(c(f" …他 {rest} 件", DIM))
1467
+ else:
1468
+ print(c(" TODO/FIXME等: 0件", DIM))
1469
+
1470
+ if cfg.show_tests:
1471
+ print(c(f" テスト未整備: {stats['no_test']} ファイル", DIM))
1472
+
1473
+ if cfg.show_entry:
1474
+ print(c(f" エントリーポイント候補: {stats['entries']} 件検出", DIM))
1475
+
1476
+ if cfg.show_imports and cfg.imported_by_map:
1477
+ top = sorted(cfg.imported_by_map.items(), key=lambda kv: -len(kv[1]))[:5]
1478
+ print(c(" 依存度が高いファイル(多くのファイルから参照されている):", DIM))
1479
+ for relpath, importers in top:
1480
+ print(c(f" {relpath} (used by {len(importers)})", DIM))
1481
+
1482
+ if cfg.show_git and not cfg.git_map:
1483
+ print(c(" (gitリポジトリではないか、git未インストールのためコミット情報は取得できませんでした)", DIM))
1484
+
770
1485
  if args.markdown: print("```")
771
1486
 
772
1487
  if args.copy:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.10",
3
+ "version": "1.0.11",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"