async-edges
Build async/event continuation edges into per-target snapshot DB so interprocedural taint reaches handler bodies. Run after js-index + db-isolate + implicit-tags, before extract_chains_bounded or chai
SKILL.md.claude/skills/async-edges/SKILL.md
GitHubasync-edges
Purpose
The static callgraph already captures f() calls but misses the
handler dispatch through async producers:
el.addEventListener('click', onClick); // onClick callee but no edge
Promise.resolve(x).then(handler); // handler unreachable
window.addEventListener('message', recv);// postMessage taint orphan
setTimeout(fn, 10); // timer callbacks invisible
This skill scans edges.raw for producer patterns, resolves each
handler reference to a node id, and writes a synthetic edge with
edge_class='continuation' plus a continuations metadata row.
Downstream chain extraction picks them up identically to sync edges.
Plan: plans/ARCHITECTURE_EVOLUTION.md §3.
Producers detected
| Producer | Handler arg | Event-taint paths |
|---|---|---|
addEventListener('message', fn) |
arg[1] | data, origin, source |
addEventListener('*', fn) |
arg[1] | target.value, detail, data |
.onmessage = fn |
arg[0] | data, origin, source |
.then(fn) |
arg[0] | __resolved__ |
.catch(fn) |
arg[0] | __rejected__ |
.finally(fn) |
arg[0] | — (not taint-through) |
setTimeout / setInterval / setImmediate / requestAnimationFrame / requestIdleCallback |
arg[0] | — (not taint-through) |
new MutationObserver(fn) |
arg[0] | 0.target |
new IntersectionObserver(fn) |
arg[0] | — |
.subscribe(fn) (RxJS) |
arg[0] | __emit__ |
ws.onmessage = fn |
arg[0] | data |
fetch(...).then(fn) |
arg[0] | __resolved__ |
When to run
After js-index has populated targets/<name>/db/js_analyzer.db,
ideally also after implicit-tags so closure-expanded tags can flow
through continuation edges. Before chain-triage /
extract_chains_bounded so the new edges enter the BFS.
Skip if status.json.phases.async_edges.status == "done" and the
snapshot has not been re-indexed since.
Steps
Build edges:
python3 bin/build_async_edges.py <target>Idempotent — prior continuation edges are cleared. Output JSON reports detected matches, inserted edges, and breakdown by producer kind.
Optional dry-run (does not modify the DB):
python3 bin/build_async_edges.py <target> --dry-runSanity-check via SQL:
sqlite3 targets/<name>/db/js_analyzer.db \ "SELECT producer_kind, COUNT(*) FROM continuations GROUP BY producer_kind ORDER BY 2 DESC;"
Outputs
- Schema change:
edgestable gainsedge_classcolumn (default'sync'). All new continuation rows carryedge_class='continuation'andresolved_kind='continuation'. - New table
continuations:(caller_id, line, callee_raw)PKproducer_kind,event_taint_paths(JSON list),taint_through,handler_is_inline
status.json.phases.async_edgesgets{status, edges_inserted, continuations_inserted, by_kind, elapsed_s, ts}.
Failure modes
- Handler text is
obj.method.deeply.nestedand the last segment has many candidates → handler resolution falls back to the first same-name node (low confidence). Same fallback the regularname_matchresolver uses. - Inline arrow handlers (no name): we match by file + ±5-line proximity to the call line. If the anonymous handler's start line is more than 5 lines away from the call, the edge is dropped (rare in practice).
- Heavily minified bundle where handler text is
nora: same-name fallback may produce phantom edges. The chain extractor's--max-name-match-candidatesthreshold defends against this; the continuation edge follows the same rule because it inherits the resolved-kind drop logic inextract_chains_bounded._load_edges.
Boundary conditions
- No edges in DB at all: 0 matches, 0 inserts; status still written.
- ast_extractor.js has not been re-run since older snapshots: pattern
still works — it scans
edges.rawonly.