先日、ある Zenn の記事で「ドキュメントがまったく無い Rails アプリをエージェントに解析させ、仕様書にした」という体験談を読みました。読み終えて最初に浮かんだのは、感心よりも先に、「自分なら最初の一手で何を渡すだろう」という疑問でした。
数年前に自分で書いたアプリでも、半年触らないと入口がどこだったか分からなくなります。そこでフォルダごとエージェントに渡したくなるのですが、私はこれで一度、読ませすぎて要約がぼやけたことがあります。
渡す量は、読ませたい深さに合わせて決めます。 いまはこの線引きにしています。
ここからは、読ませる順番を「勘」ではなく数字で出すために書いた小さなスクリプトと、手元のプロジェクトで測った結果をお伝えします。
最初に読ませるのは「よく呼ばれるファイル」と「入口」
仕様書に起こしたいとき、必要なのは全ファイルではありません。読む価値が高いのは、おおむね次の 2 種類です。
- 入口: ルーティング、ページ、ミドルウェア、設定。外から見える振る舞いが決まる場所です。
- 被参照数が多いファイル: 他の多くのファイルが import している共通部品。ここを誤解すると、仕様書全体がずれます。
後者は、import 文を数えれば機械的に出せます。ファイルごとに「何本のファイルから import されているか」を数えたものを、ここでは fan-in と呼びます。
数えるスクリプト(標準ライブラリのみ・36 行)
TypeScript / JavaScript のプロジェクトを対象にした版です。相対 import と @/ エイリアスだけを解決し、外部パッケージは数えません。
#!/usr/bin/env python3
"""reading_order.py — 既存コードの「読ませる順番」を fan-in とエントリポイントで出す。標準ライブラリのみ。"""
import os, re, sys, json, collections
root = os.path.abspath(sys.argv[1]); top_n = int(sys.argv[2]) if len(sys.argv) > 2 else 12
SKIP = {"node_modules", ".git", ".next", "dist", "build", "public", "content", "__pycache__"}
EXT = (".ts", ".tsx", ".js", ".mjs", ".jsx")
ENTRY = re.compile(r"(^|/)(page|route|layout|middleware|proxy)\.(t|j)sx?$")
IMP = re.compile(r"""(?:from|import)\s*\(?\s*['"]([^'"]+)['"]""")
files, size = [], {}
for d, ds, fs in os.walk(root):
ds[:] = [x for x in ds if x not in SKIP]
for f in fs:
if f.endswith(EXT):
p = os.path.relpath(os.path.join(d, f), root); files.append(p); size[p] = os.path.getsize(os.path.join(root, p))
fset = set(files)
def resolve(src, spec):
if spec.startswith("@/"): base = os.path.join("src", spec[2:])
elif spec.startswith("."): base = os.path.normpath(os.path.join(os.path.dirname(src), spec))
else: return None
for c in [base] + [base + e for e in EXT] + [os.path.join(base, "index" + e) for e in EXT]:
if c in fset: return c
fan = collections.Counter()
for p in files:
try: t = open(os.path.join(root, p), encoding="utf-8").read()
except Exception: continue
for m in set(IMP.findall(t)):
r = resolve(p, m)
if r and r != p: fan[r] += 1
entries = [p for p in files if ENTRY.search(p)]
total = sum(size.values())
ranked = [p for p, _ in fan.most_common(top_n)]
pick = list(dict.fromkeys(ranked + entries[:top_n]))
pb = sum(size[p] for p in pick)
print(json.dumps({"files": len(files), "total_bytes": total, "entries": len(entries),
"picked": len(pick), "picked_bytes": pb, "ratio_pct": round(pb * 100 / total, 1)}, ensure_ascii=False))
for p in ranked: print(f"fan-in {fan[p]:>3} {size[p]:>6}B {p}")使い方は python3 reading_order.py <プロジェクトのルート> 8 です。第 2 引数は fan-in 上位から何本を採るかを表します。出力の 1 行目に、全体のファイル数・総バイト数・採用ファイル数・採用バイト数・割合が JSON で出ます。
77 ファイルのプロジェクトで測った結果
手元の Next.js プロジェクト(TypeScript / JavaScript が 77 ファイル、合計 496,852 バイト。記事本文のデータとビルド成果物は除外)で、採る本数を変えて 3 回ずつ走らせました。実行は 1 回 0.03 秒で、3 回とも同じ値でした。
| fan-in 上位の本数 | 採用ファイル数(入口を含む) | 採用バイト数 | 全体に対する割合 |
|---|---|---|---|
| 8 | 16 | 57,429 | 11.6% |
| 12 | 24 | 89,730 | 18.1% |
| 20 | 40 | 243,842 | 49.1% |
上位 8 本に入口を足した 16 ファイルで、全体の約 1 割強に収まりました。ところが 20 本まで広げると、割合は一気に約半分になります。12 本から 20 本のあいだに、サイズの大きいファイルが混ざったからです。
実際、fan-in 上位 8 本から、目立つ 4 本を抜き出します(順位は 1・2・3・8 位)。
| fan-in | バイト数 | ファイル |
|---|---|---|
| 19 | 7,491 | src/lib/content.ts |
| 9 | 6,983 | src/config/pricing.ts |
| 7 | 1,051 | src/components/ui/LevelBadge.tsx |
| 2 | 15,129 | src/config/gone-slugs.ts |
最後の行が、このスクリプトの限界を教えてくれました。fan-in は 2 と小さいのに、15,129 バイトもあります。中身は URL の一覧データで、仕様を読み解く役には立たないファイルです。数字だけで機械的に採ると、こういう「大きいが読んでも得るものが少ない」ファイルが紛れ込みます。
数字は叩き台、最後は目で外す
この結果から、私は次の運用にしました。
- 最初は上位 8 本と入口だけにして、全体の 1 割強に抑えます
- 一覧を見て、データ置き場・生成物・定数表を手で外します
- 下書きに「この関数はどこから来たのか」と分からない箇所が出たら、そのとき初めて採用本数を 12、20 と広げます
広げる判断を「下書きで詰まった箇所」に結びつけておくと、読ませすぎを防げます。
なお、今回確かめたのはスクリプトの出力だけです。エージェントに実際に読ませて、仕様書の質がどう変わるかまでは測っていません。そこは別の機会に、同じ題材で並べて比べるつもりでおります。
次の一歩
まず、いま気になっている古いプロジェクトで、上のスクリプトを 8 本設定で 1 度走らせてみてください。出てきた一覧に「これは読ませても意味がない」と感じるファイルがいくつあるか。その数が、あなたのプロジェクトで手作業の除外が必要になる量の目安になります。