import os, re, subprocessfrom collections import defaultdict, dequeEXTS = [".ts", ".tsx", ".mts", ".js", ".mjs", ".jsx"]# import 文と副作用 import の両方を拾うIMP = re.compile( r'''(?:^|\n)\s*(?:import|export)\b[^;\n]*?from\s*['"]([^'"]+)['"]''' r'''|(?:^|\n)\s*import\s*['"]([^'"]+)['"]''')# re-export だけで構成された index.ts を「バレル」として印を付けるREEXP = re.compile(r'''(?:^|\n)\s*export\s+(?:\*|\{)[^;]*?from\s*['"]''')def resolve(base_dir, spec, fileset): """相対指定を実ファイルへ解決する。解決できなければ None。""" if not spec.startswith("."): return None raw = os.path.normpath(os.path.join(base_dir, spec)) cands = [raw] + [raw + e for e in EXTS] cands += [os.path.join(raw, "index" + e) for e in EXTS] # ESM の相対指定は .js と書いて .ts を指すことがある if raw.endswith(".js"): cands += [raw[:-3] + e for e in (".ts", ".tsx", ".mts")] for c in cands: if c in fileset: return c return Nonedef build(repo): out = subprocess.run(["git", "-C", repo, "ls-files"], capture_output=True, text=True).stdout paths = [p for p in out.split("\n") if p.strip() and os.path.splitext(p)[1] in EXTS] fileset = set(paths) fwd, barrel = defaultdict(set), set() for p in paths: try: src = open(os.path.join(repo, p), encoding="utf-8", errors="ignore").read() except OSError: continue # サブモジュール等で実体が無い場合は飛ばす if os.path.basename(p).startswith("index.") and len(REEXP.findall(src)) >= 3: barrel.add(p) d = os.path.dirname(p) for m in IMP.finditer(src): t = resolve(d, m.group(1) or m.group(2), fileset) if t and t != p: fwd[p].add(t) rev = defaultdict(set) for a, bs in fwd.items(): for b in bs: rev[b].add(a) return paths, rev, barrel
def is_test(p): return (".spec." in p) or (".test." in p) or ("__tests__/" in p) or ("/e2e/" in p)def closure_tests(start, rev, tests, skip=frozenset()): """start を変更したとき、import 経路で到達するテストの集合。""" seen, hit = {start}, set() q = deque([start]) while q: cur = q.popleft() for importer in rev.get(cur, ()): if importer in seen or importer in skip: continue seen.add(importer) if importer in tests: hit.add(importer) q.append(importer) return hit