dirlens 1.0.9 → 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 +79 -18
  2. package/dirlens.py +803 -63
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -119,7 +119,7 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
119
119
  - **拡張子統計** — ツリー全体のファイル種別を集計してサマリーに表示
120
120
  - **`.gitignore` 対応** — `-g` で除外(サブディレクトリも対応・否定パターン `!` 対応)
121
121
  - **最終更新日時** — `--date` / `-D` で相対表示
122
- - **拡張子フィルタ** — `-t py` など指定した拡張子のみ表示
122
+ - **拡張子フィルタ** — `-e py` など指定した拡張子のみ表示
123
123
  - **パターンフィルタ** — `--exclude` / `-I`、`--include` / `-P` でワイルドカード指定(複数可)
124
124
  - **サイズフィルタ** — `--min-size` / `--max-size` で容量による絞り込み
125
125
  - **空ディレクトリの剪定** — `--prune` でフィルタ後に空になる枝を非表示(tree --prune 互換)
@@ -132,56 +132,89 @@ Desktop/ (2 dirs, 2 files, 3.74 MB)
132
132
  - **Markdown出力** — `-m` でコードブロック形式に出力
133
133
  - **JSON出力** — `--json` / `-J` で機械可読な構造データを出力
134
134
  - **HTMLレポート** — `--html` でブラウザで閲覧できる折りたたみツリーを生成
135
- - **クリップボードコピー** — `-c` で出力を自動コピー(ANSIコードを自動除去)
136
- - **AIモード** — `--ai` 一発で gitignore除外・日時・Markdown・クリップボードコピーを全適用
135
+ - **クリップボードコピー** — `-C` で出力を自動コピー(ANSIコードを自動除去)
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
- dirlens --ai -d 3 # 深さ指定と組み合わせ可
160
+ dirlens --ai -L 3 # 深さ指定と組み合わせ可
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/依存関係を解析
148
175
 
149
176
  # ── 表示制御 ──────────────────────────────────────────────────
150
177
  dirlens # カレントディレクトリ
151
178
  dirlens ~/Desktop # 指定ディレクトリ
152
- dirlens -d 2 # 深さ2階層まで(tree -L 互換は -L 2)
153
- dirlens -L 2 # 同上(tree -L 互換)
179
+ dirlens -L 2 # 深さ2階層まで(tree -L 互換)
180
+ dirlens -d # ディレクトリのみ表示(tree -d 互換)
154
181
  dirlens -a # 隠しファイルも表示
155
182
  dirlens -r # 逆順ソート(tree -r 互換)
156
183
  dirlens --filesfirst # ファイルをディレクトリより先に表示
157
184
  dirlens -f # ルートからのフルパスで表示(tree -f 互換)
158
185
 
159
186
  # ── フィルタリング ────────────────────────────────────────────
160
- dirlens -g # .gitignore のファイルを除外
161
- dirlens -g --prune # gitignore除外 + 空になった枝を剪定
162
- dirlens -t py # .py のみ表示
187
+ dirlens -G # .gitignore のファイルを除外
188
+ dirlens -G --prune # gitignore除外 + 空になった枝を剪定
189
+ dirlens -e py # .py のみ表示
163
190
  dirlens -P '*.md' # .md のみ表示(tree -P 互換)
164
191
  dirlens -I '*.log' # .log を除外(tree -I 互換)
165
192
  dirlens --exclude 'dist' --exclude '*.log' # 複数除外
166
193
  dirlens --min-size 1M # 1MB 以上のファイルのみ
167
194
  dirlens --max-size 100K # 100KB 以下のファイルのみ
168
195
 
196
+ # ── ソート ────────────────────────────────────────────────────
197
+ dirlens -S # サイズの大きい順に表示
198
+ dirlens -t # 更新日時順にソート(新しい順・tree -t 互換)
199
+ dirlens -c # ステータス変更日時順にソート(tree -c 互換)
200
+ dirlens -t -r # 更新日時順・古い順
201
+
169
202
  # ── 詳細情報 ──────────────────────────────────────────────────
170
- dirlens -s # サイズの大きい順に表示
171
203
  dirlens --date # 最終更新日時を相対表示(tree -D 互換は -D)
172
204
  dirlens -D # 同上(tree -D 互換)
173
205
  dirlens --bar # ディスク占有率バーを表示
174
206
  dirlens --emoji # 絵文字アイコンを表示
175
207
  dirlens -p # パーミッションを表示(tree -p 互換)
176
208
  dirlens -u # 所有者名を表示(tree -u 互換)
177
- dirlens -p -u # 両方表示
209
+ dirlens -g # グループ名を表示(tree -g 互換)
210
+ dirlens -p -u -g # 全部表示
178
211
  dirlens -l # シンボリックリンク先を展開(tree -l 互換)
179
212
 
180
213
  # ── 出力形式 ──────────────────────────────────────────────────
181
214
  dirlens -m # Markdown コードブロック形式で出力
182
215
  dirlens --json # JSON 形式(tree -J 互換は -J)
183
216
  dirlens --html # HTML レポートを生成(デフォルト: dirlens.html)
184
- dirlens -c # クリップボードにコピー
217
+ dirlens -C # クリップボードにコピー
185
218
  dirlens --no-color # カラーなし(tree -n 互換は -n)
186
219
  dirlens --no-color > dirlens.txt # テキストファイルに書き出す
187
220
  ```
@@ -193,11 +226,13 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
193
226
  | オプション | 省略形 | 説明 |
194
227
  |---------------------|--------------|-------------------------------------------------------------|
195
228
  | `path` | — | 対象ディレクトリ(省略時はカレント) |
196
- | **`--ai`** | — | **`-G --date -m -C` のショートカット。AIチャット用** |
229
+ | **`--ai`** | — | **`-G --date -m -C` のショートカット。人間がAIチャットに貼り付ける用** |
230
+ | **`--agent`** | — | **`-G --date -T -H -K -V -N -O -M` のショートカット。エージェント向け解析(クリップボードは使わない)** |
197
231
  | `--depth N` | `-L N` | 表示する最大の深さ |
198
232
  | `--all` | `-a` | 隠しファイル・ディレクトリも表示 |
199
233
  | `-d` | — | ディレクトリのみ表示 |
200
234
  | `--sort-size` | `-S` | サイズが大きい順に並べる |
235
+ | `-s` | — | サイズ表示(tree互換・常時表示されているため実質no-op) |
201
236
  | `-t` | — | 更新日時順にソート(新しい順) |
202
237
  | `-c` | — | ステータス変更日時順にソート |
203
238
  | `--reverse` | `-r` | ソート順を逆にする |
@@ -217,6 +252,13 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
217
252
  | `--max-size SIZE` | — | 指定サイズ以下のファイルのみ表示 |
218
253
  | `--bar` | — | ディスク占有率バーを表示 |
219
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/依存関係を解析して表示(外部パッケージは対象外) |
220
262
  | `--markdown` | `-m` | Markdown コードブロック形式で出力(カラー自動無効) |
221
263
  | `--json` | `-J` | JSON 形式で標準出力に出力 |
222
264
  | `--html [FILE]` | — | HTML レポートを生成(デフォルト: `dirlens.html`) |
@@ -241,9 +283,28 @@ dirlens --no-color > dirlens.txt # テキストファイルに書き出す
241
283
 
242
284
  - ディレクトリのサイズは **全サブファイルの合計**(隠しファイルを含む、`.gitignore` 対象も含む)
243
285
  - ディレクトリサイズはルート直下を **並列プリフェッチ** して高速化(透過的な最適化)
286
+ - **`+` 表記** — 一部のサブディレクトリが読めなかった場合、サイズは `1.5+ KB`(少なくとも 1.5 KB)、件数は `3+ dirs` のように表示。個々のファイルサイズは親ディレクトリのスキャン時に取得できるので `+` なし(正確な値)
287
+ - **アクセス拒否** — 読めないディレクトリは赤太字で `[アクセス拒否]` を表示してスキップ。そのディレクトリは `0+ dirs, 0+ files, 0+ bytes` として表示される
244
288
  - **シンボリックリンク** は `→ リンク先パス` で表示。`-l` でリンク先ディレクトリを展開(循環検出あり)
245
- - 権限がないディレクトリは `[アクセス拒否]` と表示してスキップ
246
- - 非常に深いディレクトリ(1万階層以上)は `-d`/`-L` で深さを制限してください
289
+ - 非常に深いディレクトリ(1万階層以上)は `-L` で深さを制限してください
247
290
  - **ホームフォルダ(`~/`)やルート(`/`)で実行すると固まる場合があります** — サイズ計算は表示深さに関わらず底まで全再帰するため、`~/Library` や iCloud Drive など大容量ディレクトリで時間がかかります
248
- - **`-g` の否定パターン(`!`)** は対応していますが、ディレクトリを除外した後にその中のファイルを `!` で復活させることは非対応です(`*.log` + `!important.log` のようなファイル単位の否定は動作します)
249
- - `-p`(パーミッション)・`-u`(ユーザー名)は macOS / Linux のみ対応(Windows では UID 番号を表示)
291
+ - **`-G` の否定パターン(`!`)** は対応していますが、ディレクトリを除外した後にその中のファイルを `!` で復活させることは非対応です(`*.log` + `!important.log` のようなファイル単位の否定は動作します)
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,21 +26,26 @@ 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"; YELLOW = "\033[33m"
29
30
 
30
31
  def c(text, *codes):
31
32
  return ("".join(codes) + text + RESET) if USE_COLOR else text
32
33
 
33
34
 
34
35
  # ─── フォーマット ─────────────────────────────────────────────
35
- def fmt_size(n):
36
- if n == 0: return "0 bytes"
36
+ def fmt_size(n, partial=False):
37
+ sfx = "+" if partial else ""
38
+ if n == 0: return f"0{sfx} bytes"
37
39
  for unit, f in (("TB",1<<40),("GB",1<<30),("MB",1<<20),("KB",1<<10)):
38
40
  if n >= f:
39
- return f"{str(f'{n/f:.2f}').rstrip('0').rstrip('.')} {unit}"
40
- return f"{n} {'byte' if n==1 else 'bytes'}"
41
+ return f"{str(f'{n/f:.2f}').rstrip('0').rstrip('.')}{sfx} {unit}"
42
+ return f"{n}{sfx} {'byte' if (n==1 and not partial) else 'bytes'}"
41
43
 
42
- def fmt_count(nd, nf):
43
- return f"{nd} {'dir' if nd==1 else 'dirs'}, {nf} {'file' if nf==1 else 'files'}"
44
+ def fmt_count(nd, nf, denied=False):
45
+ sfx = "+" if denied else ""
46
+ d_word = "dir" if (nd == 1 and not denied) else "dirs"
47
+ f_word = "file" if (nf == 1 and not denied) else "files"
48
+ return f"{nd}{sfx} {d_word}, {nf}{sfx} {f_word}"
44
49
 
45
50
  def fmt_date(mtime):
46
51
  sec = int((datetime.datetime.now() - datetime.datetime.fromtimestamp(mtime)).total_seconds())
@@ -114,9 +119,8 @@ def copy_to_clipboard(text):
114
119
  return False
115
120
  except Exception: return False
116
121
 
117
- import re as _re
118
122
  def strip_ansi(text):
119
- return _re.sub(r'\033\[[0-9;]*[mK]', '', text)
123
+ return re.sub(r'\033\[[0-9;]*[mK]', '', text)
120
124
 
121
125
 
122
126
  # ─── 絵文字 ───────────────────────────────────────────────────
@@ -208,8 +212,10 @@ def _extend_pats(active_pats, path, cfg):
208
212
  _sz_cache = {}
209
213
 
210
214
  def dir_size(path):
215
+ """ディレクトリの合計サイズを返す。(size, has_errors) のタプル。"""
211
216
  if path in _sz_cache: return _sz_cache[path]
212
217
  total = 0
218
+ has_errors = False
213
219
  try:
214
220
  with os.scandir(path) as it:
215
221
  for e in it:
@@ -217,11 +223,16 @@ def dir_size(path):
217
223
  if e.is_file(follow_symlinks=False):
218
224
  total += e.stat(follow_symlinks=False).st_size
219
225
  elif e.is_dir(follow_symlinks=False):
220
- total += dir_size(e.path)
221
- except OSError: pass
222
- except OSError: pass
223
- _sz_cache[path] = total
224
- return total
226
+ sub_sz, sub_err = dir_size(e.path)
227
+ total += sub_sz
228
+ if sub_err: has_errors = True
229
+ except OSError:
230
+ has_errors = True
231
+ except OSError:
232
+ has_errors = True
233
+ result = (total, has_errors)
234
+ _sz_cache[path] = result
235
+ return result
225
236
 
226
237
  def _prefetch_sizes(root_path):
227
238
  try:
@@ -234,6 +245,537 @@ def _prefetch_sizes(root_path):
234
245
  list(ex.map(dir_size, top))
235
246
 
236
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
+
237
779
  # ─── 設定クラス ───────────────────────────────────────────────
238
780
  class Cfg:
239
781
  def __init__(self, args, root):
@@ -262,11 +804,30 @@ class Cfg:
262
804
  self.reverse = args.reverse
263
805
  self.files_first = args.filesfirst
264
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
+
265
826
 
266
827
  # ─── 共通フィルタリング ───────────────────────────────────────
267
828
  def _filter(path, cfg, active_pats):
268
829
  try: raw = list(os.scandir(path))
269
- except PermissionError: return [], []
830
+ except PermissionError: return None, None # アクセス拒否シグナル
270
831
 
271
832
  entries = [e for e in raw if cfg.show_all or not e.name.startswith(".")]
272
833
 
@@ -285,7 +846,6 @@ def _filter(path, cfg, active_pats):
285
846
  and e.is_dir(follow_symlinks=True)]
286
847
  dirs = dirs + sym_dirs
287
848
 
288
- # -d (dirs only): ファイルを非表示
289
849
  if cfg.dirs_only:
290
850
  files = []
291
851
  else:
@@ -316,12 +876,14 @@ def _filter(path, cfg, active_pats):
316
876
  def count_entries(path, cfg, active_pats):
317
877
  pats = _extend_pats(active_pats, path, cfg)
318
878
  dirs, files = _filter(path, cfg, pats)
319
- return len(dirs), len(files)
879
+ if dirs is None: return 0, 0, True # アクセス拒否
880
+ return len(dirs), len(files), False
320
881
 
321
882
  def _has_content(path, depth, cfg, active_pats):
322
883
  if cfg.max_depth is not None and depth >= cfg.max_depth: return False
323
884
  pats = _extend_pats(active_pats, path, cfg)
324
885
  dirs, files = _filter(path, cfg, pats)
886
+ if dirs is None: return False
325
887
  if files: return True
326
888
  for d in dirs:
327
889
  if _has_content(d.path, depth + 1, cfg, pats): return True
@@ -330,7 +892,6 @@ def _has_content(path, depth, cfg, active_pats):
330
892
 
331
893
  # ─── ソートヘルパー ───────────────────────────────────────────
332
894
  def _sort_entries(dirs, files, cfg):
333
- """設定に基づいてdirs/filesをソートする(-t/-c/-S/-r対応)。"""
334
895
  def emtime(e):
335
896
  try: return e.stat(follow_symlinks=True).st_mtime
336
897
  except OSError: return 0
@@ -342,16 +903,16 @@ def _sort_entries(dirs, files, cfg):
342
903
  except OSError: return 0
343
904
 
344
905
  rev = cfg.reverse
345
- if cfg.sort_mtime: # -t: 更新日時順(新しい順)
906
+ if cfg.sort_mtime:
346
907
  dirs.sort(key=emtime, reverse=not rev)
347
908
  files.sort(key=emtime, reverse=not rev)
348
- elif cfg.sort_ctime: # -c: ctime順(新しい順)
909
+ elif cfg.sort_ctime:
349
910
  dirs.sort(key=ectime, reverse=not rev)
350
911
  files.sort(key=ectime, reverse=not rev)
351
- elif cfg.by_size: # -S: サイズ順(大きい順)
352
- dirs.sort(key=lambda e: dir_size(e.path), reverse=not rev)
912
+ elif cfg.by_size:
913
+ dirs.sort(key=lambda e: dir_size(e.path)[0], reverse=not rev)
353
914
  files.sort(key=esz, reverse=not rev)
354
- else: # デフォルト: アルファベット順
915
+ else:
355
916
  dirs.sort(key=lambda e: e.name.casefold(), reverse=rev)
356
917
  files.sort(key=lambda e: e.name.casefold(), reverse=rev)
357
918
 
@@ -374,13 +935,16 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
374
935
 
375
936
  cur_pats = _extend_pats(active_pats, path, cfg)
376
937
  dirs, files = _filter(path, cfg, cur_pats)
938
+ if dirs is None:
939
+ print(f"{prefix}{LAST}{c('[アクセス拒否]', BOLD, RED)}")
940
+ return
377
941
 
378
942
  if cfg.prune:
379
943
  dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, cur_pats)]
380
944
 
381
945
  dirs, files = _sort_entries(dirs, files, cfg)
382
946
  combined = (files + dirs) if cfg.files_first else (dirs + files)
383
- cur_dir_size = dir_size(path)
947
+ cur_dir_size, _ = dir_size(path)
384
948
 
385
949
  def esz(e):
386
950
  try: return e.stat(follow_symlinks=True).st_size
@@ -409,12 +973,12 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
409
973
  (cfg.follow_syms and entry.is_symlink() and entry.is_dir(follow_symlinks=True))
410
974
 
411
975
  if is_dir_entry:
412
- sz = dir_size(entry.path)
413
- nd, nf = count_entries(entry.path, cfg, cur_pats)
976
+ sz, sz_err = dir_size(entry.path)
977
+ nd, nf, denied = count_entries(entry.path, cfg, cur_pats)
414
978
  stats["dirs"] += 1
415
979
 
416
980
  emoji = (get_emoji(entry.name, is_dir=True) + " ") if cfg.show_emoji else ""
417
- parts = [fmt_count(nd, nf), fmt_size(sz)]
981
+ parts = [fmt_count(nd, nf, denied), fmt_size(sz, sz_err)]
418
982
  if cfg.show_date:
419
983
  try: parts.append(fmt_date(entry.stat(follow_symlinks=False).st_mtime))
420
984
  except OSError: pass
@@ -432,27 +996,70 @@ def render(path, prefix, depth, cfg, stats, active_pats, _seen=None):
432
996
  stats["extensions"][ext or "(no ext)"] = \
433
997
  stats["extensions"].get(ext or "(no ext)", 0) + 1
434
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
+
435
1007
  emoji = (get_emoji(entry.name) + " ") if cfg.show_emoji else ""
436
1008
  parts = [fmt_size(sz)]
437
1009
  if cfg.show_date:
438
1010
  mt = emtime(entry)
439
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
+
440
1042
  bar = (" " + fmt_bar(sz, cur_dir_size)) if cfg.show_bar and cur_dir_size else ""
441
1043
 
442
- name = c(f"{emoji}{display}{sym_target}",
1044
+ name = c(f"{entry_mark}{display}{sym_target}",
443
1045
  MAGENTA if entry.is_symlink() else GREEN)
444
1046
  meta = c(f"({', '.join(parts)}){bar}", DIM)
445
1047
  print(f"{prefix}{branch}{perm_prefix}{name} {meta}")
446
1048
 
447
1049
 
448
1050
  # ─── JSON出力 ─────────────────────────────────────────────────
449
- 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
+
450
1055
  cur_pats = _extend_pats(active_pats, path, cfg)
451
1056
  dirs, files = _filter(path, cfg, cur_pats)
1057
+ denied = dirs is None
1058
+ if denied: dirs, files = [], []
452
1059
  if cfg.prune:
453
1060
  dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, cur_pats)]
454
1061
  dirs, files = _sort_entries(dirs, files, cfg)
455
- sz = dir_size(path)
1062
+ sz, sz_err = dir_size(path)
456
1063
 
457
1064
  def esz(e):
458
1065
  try: return e.stat(follow_symlinks=True).st_size
@@ -463,22 +1070,55 @@ def build_json_tree(path, depth, cfg, active_pats):
463
1070
  combined = (files + dirs) if cfg.files_first else (dirs + files)
464
1071
  for entry in combined:
465
1072
  if entry.is_dir(follow_symlinks=False):
466
- 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))
467
1074
  else:
468
1075
  f_sz = esz(entry)
469
- 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 = {
470
1080
  "name": entry.name, "type": "file",
471
1081
  "size": f_sz, "size_human": fmt_size(f_sz),
472
1082
  "ext": os.path.splitext(entry.name)[1].lower(),
473
- "path": os.path.relpath(entry.path, cfg.root),
474
- })
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)
475
1114
 
476
1115
  name = os.path.basename(path) or path
477
1116
  return {
478
1117
  "name": name, "type": "directory",
479
- "size": sz, "size_human": fmt_size(sz),
1118
+ "size": sz, "size_human": fmt_size(sz, sz_err),
480
1119
  "path": os.path.relpath(path, cfg.root) if path != cfg.root else ".",
481
- "item_count": {"dirs": len(dirs), "files": len(files)},
1120
+ "item_count": {"dirs": len(dirs), "files": len(files),
1121
+ "permission_denied": denied},
482
1122
  "children": children,
483
1123
  }
484
1124
 
@@ -488,10 +1128,12 @@ def generate_html(root_path, cfg, active_pats):
488
1128
  def _node(path, depth, cur_pats):
489
1129
  pats = _extend_pats(cur_pats, path, cfg)
490
1130
  dirs, files = _filter(path, cfg, pats)
1131
+ denied = dirs is None
1132
+ if denied: dirs, files = [], []
491
1133
  if cfg.prune:
492
1134
  dirs = [d for d in dirs if _has_content(d.path, depth + 1, cfg, pats)]
493
1135
  dirs, files = _sort_entries(dirs, files, cfg)
494
- sz = dir_size(path)
1136
+ sz, sz_err = dir_size(path)
495
1137
  name = os.path.basename(path) or path
496
1138
 
497
1139
  def esz(e):
@@ -505,23 +1147,35 @@ def generate_html(root_path, cfg, active_pats):
505
1147
  if cfg.max_depth is None or depth < cfg.max_depth:
506
1148
  ch += _node(entry.path, depth + 1, pats)
507
1149
  else:
1150
+ e_sz, e_err = dir_size(entry.path)
508
1151
  ch += (f'<div class="item dir-leaf">📁 {entry.name}/'
509
- f' <span class="sz">{fmt_size(dir_size(entry.path))}</span></div>\n')
1152
+ f' <span class="sz">{fmt_size(e_sz, e_err)}</span></div>\n')
510
1153
  else:
511
1154
  f_sz = esz(entry)
512
1155
  sym = ""
513
1156
  if entry.is_symlink():
514
1157
  try: sym = f' → {os.readlink(entry.path)}'
515
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
+
516
1170
  ch += (f'<div class="item file">'
517
1171
  f'<span class="emoji">{get_emoji(entry.name)}</span>'
518
1172
  f'<span class="fname"> {entry.name}{sym}</span>'
519
- f'<span class="sz"> {fmt_size(f_sz)}</span></div>\n')
1173
+ f'<span class="sz"> {fmt_size(f_sz)}</span>{badges}</div>\n')
520
1174
 
521
1175
  nd, nf = len(dirs), len(files)
522
1176
  opened = " open" if depth == 0 else ""
523
1177
  return (f'<details{opened}><summary>📁 <strong>{name}/</strong>'
524
- f' <span class="sz">({fmt_count(nd, nf)}, {fmt_size(sz)})</span>'
1178
+ f' <span class="sz">({fmt_count(nd, nf, denied)}, {fmt_size(sz, sz_err)})</span>'
525
1179
  f'</summary><div class="ch">{ch}</div></details>\n')
526
1180
 
527
1181
  root_name = os.path.basename(root_path) or root_path
@@ -549,6 +1203,11 @@ summary:hover{{background:rgba(255,255,255,.06)}}
549
1203
  .item:hover{{background:rgba(255,255,255,.05);border-radius:4px}}
550
1204
  .fname{{color:#a6e3a1}}.sz{{color:#585b70;font-size:12px}}
551
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}}
552
1211
  </style></head><body>
553
1212
  <h1>🌳 dirlens — {root_name}</h1>
554
1213
  <input id="q" type="text" placeholder="ファイル名で検索…" oninput="search(this.value)">
@@ -556,18 +1215,13 @@ summary:hover{{background:rgba(255,255,255,.06)}}
556
1215
  <script>
557
1216
  function search(q){{
558
1217
  q=q.toLowerCase().trim();
559
- // まず全要素を表示に戻す
560
1218
  document.querySelectorAll('.hidden').forEach(el=>el.classList.remove('hidden'));
561
1219
  if(!q) return;
562
- // 全ディレクトリを展開
563
1220
  document.querySelectorAll('details').forEach(d=>d.open=true);
564
- // マッチしないファイルを非表示
565
1221
  document.querySelectorAll('.file').forEach(el=>{{
566
1222
  const n=el.querySelector('.fname')?.textContent.toLowerCase()||'';
567
1223
  if(!n.includes(q)) el.classList.add('hidden');
568
1224
  }});
569
- // visible なファイルを1つも持たないディレクトリを非表示
570
- // (空ディレクトリ・検索に引っかからないディレクトリ両方を処理)
571
1225
  document.querySelectorAll('#tree details').forEach(detail=>{{
572
1226
  const hasVisible=[...detail.querySelectorAll('.file')]
573
1227
  .some(f=>!f.classList.contains('hidden'));
@@ -588,15 +1242,18 @@ def main():
588
1242
  formatter_class=argparse.RawDescriptionHelpFormatter,
589
1243
  epilog=(
590
1244
  "使用例:\n"
591
- " dirlens --ai AIチャット貼り付け用(推奨)\n"
592
- " dirlens -d ディレクトリのみ表示(tree -d 互換)\n"
593
- " dirlens -L 2 深さ 2 まで表示(tree -L 互換)\n"
594
- " dirlens -t 更新日時順にソート(tree -t 互換)\n"
595
- " dirlens -G --prune gitignore除外 + 空枝を剪定\n"
596
- " dirlens -p -u -g パーミッション・ユーザー・グループ表示\n"
597
- " dirlens -e py .pyのみ表示(旧 -t)\n"
598
- " dirlens -S サイズ順ソート(旧 -s)\n"
599
- " 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"
600
1257
  " dirlens --no-color > dirlens.txt ファイルに書き出す"
601
1258
  ),
602
1259
  )
@@ -643,6 +1300,23 @@ def main():
643
1300
  ap.add_argument("-I", dest="exclude_tree", metavar="PATTERN", action="append",
644
1301
  help="除外パターン(tree -I 互換)")
645
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
+
646
1320
  # ── dirlens独自オプション ─────────────────────────────────
647
1321
  ap.add_argument("path", nargs="?", default=".")
648
1322
  ap.add_argument("--depth", type=int, metavar="N",
@@ -661,7 +1335,9 @@ def main():
661
1335
  ap.add_argument("--prune", action="store_true")
662
1336
  ap.add_argument("--filesfirst", action="store_true")
663
1337
  ap.add_argument("--ai", action="store_true",
664
- 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 のショートカット(エージェント向け解析、クリップボードは使わない)")
665
1341
  args = ap.parse_args()
666
1342
 
667
1343
  # ── エイリアスのマージ ────────────────────────────────────
@@ -672,13 +1348,25 @@ def main():
672
1348
  if args.no_color_tree: args.no_color = True
673
1349
  if args.json_tree: args.json = True
674
1350
 
675
- # --ai: -G --date -m -C のショートカット
1351
+ # --ai: 人間がAIチャットに貼り付けるためのショートカット(クリップボードを使う)
676
1352
  if args.ai:
677
1353
  args.gitignore = True
678
1354
  args.date = True
679
1355
  args.markdown = True
680
1356
  args.copy = True
681
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
+
682
1370
  if args.no_color or args.markdown or args.json:
683
1371
  USE_COLOR = False
684
1372
 
@@ -690,12 +1378,33 @@ def main():
690
1378
 
691
1379
  cfg = Cfg(args, str(target))
692
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
+
693
1388
  _prefetch_sizes(str(target))
694
1389
 
695
1390
  # ── JSON ─────────────────────────────────────────────────
696
1391
  if args.json:
697
- print(json.dumps(build_json_tree(str(target), 0, cfg, active_pats),
698
- 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
699
1408
 
700
1409
  # ── HTML ─────────────────────────────────────────────────
701
1410
  if args.html:
@@ -709,10 +1418,11 @@ def main():
709
1418
 
710
1419
  if args.markdown: print("```")
711
1420
 
712
- root_sz = dir_size(str(target))
713
- root_nd, root_nf = count_entries(str(target), cfg, active_pats)
714
- root_label = target.name if target.name else str(target)
715
- root_parts = [fmt_count(root_nd, root_nf), fmt_size(root_sz)]
1421
+ root_sz, root_sz_err = dir_size(str(target))
1422
+ root_nd, root_nf, root_denied = count_entries(str(target), cfg, active_pats)
1423
+ root_label = target.name if target.name else str(target)
1424
+
1425
+ root_parts = [fmt_count(root_nd, root_nf, root_denied), fmt_size(root_sz, root_sz_err)]
716
1426
  if args.date:
717
1427
  try: root_parts.append(fmt_date(target.stat().st_mtime))
718
1428
  except OSError: pass
@@ -721,7 +1431,8 @@ def main():
721
1431
  print(f"{c(root_emoji + root_label + '/', BOLD, BLUE)} "
722
1432
  f"{c('(' + ', '.join(root_parts) + ')', DIM)}")
723
1433
 
724
- 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}
725
1436
  render(str(target), "", 0, cfg, stats, active_pats)
726
1437
 
727
1438
  print()
@@ -742,6 +1453,35 @@ def main():
742
1453
  exts = sorted(stats["extensions"].items(), key=lambda x: -x[1])
743
1454
  print(c(" " + " ".join(f"{e} ×{n}" for e, n in exts[:8]), DIM))
744
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
+
745
1485
  if args.markdown: print("```")
746
1486
 
747
1487
  if args.copy:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dirlens",
3
- "version": "1.0.9",
3
+ "version": "1.0.11",
4
4
  "description": "Directory tree viewer with file sizes / ファイルサイズ付きディレクトリツリー表示ツール",
5
5
  "bin": {
6
6
  "dirlens": "./bin/dirlens"