#!/usr/bin/env python3
"""Reproducible census of npm lifecycle install-scripts (preinstall/install/postinstall) —
the exact vector the 2025 NPM supply-chain attacks (Shai-Hulud, chalk/debug) abused.
Two lenses on a fixed seed of ubiquitous packages:
  (1) TOP TIER  = seed + their DIRECT deps
  (2) FULL TREE = bounded transitive closure (BFS over `dependencies`, capped), i.e. what
      actually lands in node_modules when you install these.
Data: public npm registry. By-name latest is used (approximation of true semver resolution;
stated as a limitation). Point-in-time; re-run => same corpus/logic => same shape."""
import json, urllib.request, urllib.parse, concurrent.futures as cf
from collections import Counter, deque
UA={"User-Agent":"zennoxa-research/1.0 (public npm census)"}
SEED=["react","react-dom","vue","express","lodash","axios","chalk","debug","commander",
      "next","webpack","vite","typescript","eslint","jest","dotenv","moment","uuid","semver",
      "glob","rimraf","node-fetch","ws","prettier","@babel/core","rollup","esbuild","postcss",
      "tailwindcss","zod","yargs","chokidar","fs-extra","cross-env"]
LIFECYCLE=("preinstall","install","postinstall")
CAP=2500
_cache={}
def manifest(pkg):
    if pkg in _cache: return _cache[pkg]
    try:
        u="https://registry.npmjs.org/"+urllib.parse.quote(pkg,safe='@/')
        d=json.load(urllib.request.urlopen(urllib.request.Request(u,headers=UA),timeout=20))
        lat=d.get("dist-tags",{}).get("latest"); m=d.get("versions",{}).get(lat,{}) if lat else {}
    except Exception: m=None
    _cache[pkg]=m; return m

# BFS transitive closure
seen=set(SEED); q=deque(SEED); order=[]
while q and len(seen)<=CAP:
    batch=[q.popleft() for _ in range(min(64,len(q)))]
    with cf.ThreadPoolExecutor(max_workers=16) as ex:
        for pkg,m in zip(batch, ex.map(manifest,batch)):
            order.append(pkg)
            if m:
                for dep in (m.get("dependencies") or {}):
                    if dep not in seen and len(seen)<=CAP:
                        seen.add(dep); q.append(dep)
tree=sorted(seen)
# direct-only corpus
direct=set(SEED)
for s in SEED:
    m=manifest(s)
    if m: direct|=set((m.get("dependencies") or {}).keys())
direct=sorted(direct)

def scan(names):
    fetched=0; hit={}; 
    with cf.ThreadPoolExecutor(max_workers=16) as ex:
        for pkg,m in zip(names, ex.map(manifest,names)):
            if not m: continue
            fetched+=1; sc=m.get("scripts") or {}
            h={k for k in LIFECYCLE if k in sc}
            if h: hit[pkg]=h
    return fetched,hit

df,dh=scan(direct); tf,th=scan(tree)
print(f"[TOP TIER seed+direct] fetched={df}  with-install-script={len(dh)}  ({100*len(dh)/max(df,1):.1f}%)")
print(f"[FULL TREE transitive] corpus={len(tree)} fetched={tf}  with-install-script={len(th)}  ({100*len(th)/max(tf,1):.1f}%)")
c=Counter()
for v in th.values():
    for k in v: c[k]+=1
print("full-tree by type:",dict(c))
print("sample flagged (full tree):", sorted(list(th))[:25])
json.dump({"top_tier":{"fetched":df,"with_script":len(dh),"pct":round(100*len(dh)/max(df,1),1),"pkgs":sorted(dh)},
           "full_tree":{"corpus":len(tree),"fetched":tf,"with_script":len(th),"pct":round(100*len(th)/max(tf,1),1),
                        "by_type":dict(c),"pkgs":sorted(th)},
           "seed":SEED,"cap":CAP},
          open("/home/paperclip/shared/research-data/npm_install_scripts_results.json","w"),indent=1)
print("saved -> npm_install_scripts_results.json")
