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.
- package/README.md +79 -18
- package/dirlens.py +803 -63
- 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
|
-
- **拡張子フィルタ** — `-
|
|
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
|
-
- **クリップボードコピー** — `-
|
|
136
|
-
- **AI
|
|
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 -
|
|
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 -
|
|
153
|
-
dirlens -
|
|
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 -
|
|
161
|
-
dirlens -
|
|
162
|
-
dirlens -
|
|
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 -
|
|
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 -
|
|
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`
|
|
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
|
-
- **`-
|
|
249
|
-
- `-p`(パーミッション)・`-u
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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"{
|
|
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
|
-
|
|
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":
|
|
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(
|
|
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
|
|
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
|
|
592
|
-
" dirlens
|
|
593
|
-
" dirlens -
|
|
594
|
-
" dirlens -
|
|
595
|
-
" dirlens -G --prune
|
|
596
|
-
" dirlens -
|
|
597
|
-
" dirlens -
|
|
598
|
-
" dirlens -
|
|
599
|
-
" dirlens -
|
|
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:
|
|
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
|
-
|
|
698
|
-
|
|
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
|
|
715
|
-
|
|
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:
|