Skip to content

Commit 275e15a

Browse files
author
jgstern-agent
committed
feat(ts-io): emit unresolved-call edges for imported names + browser catalog (WI-banaf)
UAT BUG-09a observed 0 io-boundaries on TypeScript repos. Two structural gaps were responsible: 1. The JS/TS analyzer never emitted call edges to imported function names that didn't resolve to an intra-repo symbol. Calls like `import { existsSync } from 'node:fs'; existsSync(p)` produced no edge at all, so the io-boundaries layer had nothing to match against. Added an unresolved-edge fallback in the bare-name call path: when `func_name in named_imports` and no callee resolves, emit `<lang>:<module_hint>:0-0:<name>:unresolved` with the import path normalised (strip `node:` prefix, strip `./` `../` leaders) so the colon-delimited symbol ID stays parseable. 2. The JavaScript catalog was Node-only. Added browser/web-API entries: WebSocket / EventSource / BroadcastChannel (net), XMLHttpRequest / navigator.sendBeacon / window.fetch (net_send), localStorage / sessionStorage / indexedDB / caches (fs_read & fs_write), console.* (logging), navigator / window / document env reads, plus the common third-party HTTP clients ky / got / superagent / undici. Verified on nextjs/packages/create-next-app: total_io_edges 0 → 27 (fs_read 17 chains, fs_write 4, subprocess 6). apollo-server: 0 → 7 (logging+net_recv via console.log + http.createServer). Member calls on namespace and default imports (`fs.readFileSync` after `import * as fs`, `axios.get` after `import axios`) are not yet covered; filed as follow-up WI-vurop. Signed-off-by: jgstern-agent <josh-agent@iterabloom.com>
1 parent 479128c commit 275e15a

5 files changed

Lines changed: 211 additions & 4 deletions

File tree

.ci/affected-tests.txt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
# Test selection manifest
2-
# Generated by smart-test at 2026-04-14T05:14:04-04:00
2+
# Generated by smart-test at 2026-04-14T09:54:37-04:00
33
# Mode: targeted
44
# Baseline: 3a502998615fa04cbe60be62b61dd38520b9750b
5-
# Changed files: 11
6-
# Changed source files: 4
7-
# Selected tests: 269
5+
# Changed files: 15
6+
# Changed source files: 5
7+
# Selected tests: 270
88
#
99
# === CHANGED_SOURCE_FILES ===
1010
packages/hypergumbo-core/src/hypergumbo_core/cli.py
1111
packages/hypergumbo-core/src/hypergumbo_core/discovery.py
1212
packages/hypergumbo-core/src/hypergumbo_core/gitleaks.py
1313
packages/hypergumbo-core/src/hypergumbo_core/sketch.py
14+
packages/hypergumbo-lang-mainstream/src/hypergumbo_lang_mainstream/js_ts.py
1415
# === SELECTED_TESTS ===
1516
packages/hypergumbo-core/tests/BRANCHES_test_database_query.py
1617
packages/hypergumbo-core/tests/BRANCHES_test_graphql.py
@@ -279,5 +280,6 @@ packages/hypergumbo-lang-mainstream/tests/test_scala.py
279280
packages/hypergumbo-lang-mainstream/tests/test_sql_analyzer.py
280281
packages/hypergumbo-lang-mainstream/tests/test_swift.py
281282
packages/hypergumbo-lang-mainstream/tests/test_toml_analyzer.py
283+
packages/hypergumbo-lang-mainstream/tests/test_ts_decorator_edges.py
282284
packages/hypergumbo-lang-mainstream/tests/test_xml_analyzer.py
283285
packages/hypergumbo-lang-mainstream/tests/test_yaml_ansible.py

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,10 @@ This changelog tracks the **tool version** (package releases). The **schema vers
1818

1919
- **Cached secret-scan results across warm sketch runs** (WI-julir / UAT BUG-20): `scan_content_cached` keys gitleaks output by sha256 of the sketch content and stores up to 8 entries in the per-state results cache directory. A warm `hypergumbo sketch` now completes in roughly the `--no-secret-scan` time (~7s on alertmanager) instead of paying the ~8s gitleaks cost on every invocation. The cache invalidates automatically when the repo state hash changes (the cache lives inside the per-state directory).
2020

21+
### Added
22+
23+
- **TypeScript/JavaScript I/O boundary tracing** (WI-banaf / UAT BUG-09a): the JS/TS analyzer now emits unresolved-call edges for bare-name calls to named imports (e.g. `import { existsSync } from 'node:fs'; existsSync()`), so the io-boundaries pipeline can match them against the catalog. Browser-API entries added to the JavaScript catalog: WebSocket, EventSource, BroadcastChannel, XMLHttpRequest, navigator.sendBeacon, localStorage / sessionStorage / indexedDB / caches, console logging, navigator/window/document env reads, and HTTP-client modules ky/got/superagent/undici. Verified on `nextjs/packages/create-next-app`: total_io_edges 0 → 27 (fs_read/fs_write/subprocess populated). Member calls on namespace and default imports remain a follow-up (WI-vurop).
24+
2125
## [2.6.0] - 2026-04-12
2226

2327
### Changed

packages/hypergumbo-core/src/hypergumbo_core/io_primitives/javascript.yaml

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,20 @@ fs_read:
5858
notes: "Deno runtime file read operations"
5959
- module: Deno
6060
methods: [readFile, readTextFile, readDir, stat, lstat]
61+
# Browser persistent storage (WI-banaf): treated as fs_read because they
62+
# are persistent reads from a host-managed store.
63+
- module: localStorage
64+
methods: [getItem, key, length]
65+
notes: "Browser localStorage read"
66+
- module: sessionStorage
67+
methods: [getItem, key, length]
68+
notes: "Browser sessionStorage read"
69+
- module: indexedDB
70+
functions: [open, databases]
71+
notes: "Browser IndexedDB read"
72+
- module: caches
73+
functions: [open, match, has, keys]
74+
notes: "Browser CacheStorage read (Service Workers)"
6175

6276
fs_write:
6377
- module: fs
@@ -70,6 +84,13 @@ fs_write:
7084
- module: Deno
7185
functions: [writeFile, writeTextFile, mkdir, remove, rename, chmod, chown, truncate, create]
7286
notes: "Deno runtime file write operations"
87+
# Browser persistent storage writes (WI-banaf)
88+
- module: localStorage
89+
methods: [setItem, removeItem, clear]
90+
notes: "Browser localStorage write"
91+
- module: sessionStorage
92+
methods: [setItem, removeItem, clear]
93+
notes: "Browser sessionStorage write"
7394

7495
net_send:
7596
- module: http
@@ -99,6 +120,32 @@ net_send:
99120
- module: Deno
100121
functions: [connect, connectTls]
101122
notes: "Deno runtime network client operations"
123+
# Browser network sinks (WI-banaf)
124+
- module: WebSocket
125+
methods: [send]
126+
notes: "Browser WebSocket client send"
127+
- module: window
128+
functions: [fetch]
129+
notes: "Browser window.fetch (same as global fetch)"
130+
- module: navigator
131+
methods: [sendBeacon]
132+
notes: "Browser navigator.sendBeacon for analytics/telemetry"
133+
- module: XMLHttpRequest
134+
methods: [send, open, setRequestHeader]
135+
notes: "Browser XHR"
136+
# Common browser HTTP client libraries (resolved via named imports)
137+
- module: ky
138+
functions: [get, post, put, delete, patch, head, create]
139+
notes: "ky HTTP client (browser/Deno)"
140+
- module: superagent
141+
functions: [get, post, put, delete, patch, head]
142+
notes: "superagent HTTP client"
143+
- module: got
144+
functions: [get, post, put, delete, patch, head, request]
145+
notes: "got HTTP client (Node)"
146+
- module: undici
147+
functions: [request, fetch, stream]
148+
notes: "undici HTTP client (Node)"
102149

103150
net_recv:
104151
- module: http
@@ -125,6 +172,16 @@ net_recv:
125172
- module: koa.Application
126173
methods: [listen]
127174
notes: "Koa server listen"
175+
# Browser network sources (WI-banaf)
176+
- module: EventSource
177+
methods: [addEventListener, onmessage, onerror]
178+
notes: "Browser Server-Sent Events client"
179+
- module: WebSocket
180+
methods: [addEventListener, onmessage, onopen, onclose, onerror]
181+
notes: "Browser WebSocket client receive"
182+
- module: BroadcastChannel
183+
methods: [postMessage, addEventListener]
184+
notes: "Browser cross-tab messaging"
128185

129186
subprocess:
130187
- module: child_process
@@ -135,6 +192,16 @@ env_read:
135192
attributes: [env, argv, platform, arch, version, cwd]
136193
- module: os
137194
functions: [hostname, platform, arch, release, type, cpus, totalmem, freemem, homedir, tmpdir, userInfo]
195+
# Browser environment introspection (WI-banaf)
196+
- module: navigator
197+
attributes: [userAgent, language, languages, platform, geolocation, cookieEnabled, onLine]
198+
notes: "Browser environment / capability detection"
199+
- module: window
200+
attributes: [location, navigator, screen, document]
201+
notes: "Browser window globals"
202+
- module: document
203+
attributes: [cookie, location, referrer]
204+
notes: "Browser document globals (cookie/referrer leak fingerprintable data)"
138205

139206
ipc_send:
140207
- module: process
@@ -149,3 +216,9 @@ ipc_recv:
149216
- module: process
150217
methods: [on]
151218
notes: "process.on('message', ...) for IPC from parent"
219+
220+
logging:
221+
# Browser + Node share console.* (WI-banaf)
222+
- module: console
223+
methods: [log, info, warn, error, debug, trace, dir, table, group, groupEnd, time, timeEnd]
224+
notes: "Universal console logging (browser + Node)"

packages/hypergumbo-lang-mainstream/src/hypergumbo_lang_mainstream/js_ts.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -308,6 +308,30 @@ def _get_parser_for_file(file_path: Path) -> Optional["tree_sitter.Parser"]:
308308
return parser
309309

310310

311+
def _normalize_import_module_hint(module: str) -> str:
312+
"""Normalise an import path to a module hint usable in symbol IDs.
313+
314+
Symbol IDs are colon-delimited (``lang:module:span:name:kind``) so a
315+
raw ``node:fs`` import path would corrupt the parse downstream. We
316+
strip the ``node:`` prefix (Node 16+ canonical form for built-ins)
317+
and the relative-path leaders (``./``, ``../``) so the module hint
318+
becomes the bare module name expected by the I/O catalog
319+
(``fs``, ``child_process``, ``axios``).
320+
321+
Examples:
322+
node:fs -> fs
323+
node:child_process -> child_process
324+
./utils -> utils
325+
../helpers/git -> helpers/git
326+
@scope/pkg -> @scope/pkg (unchanged)
327+
"""
328+
if module.startswith("node:"):
329+
return module[5:]
330+
while module.startswith("./") or module.startswith("../"):
331+
module = module[2:] if module.startswith("./") else module[3:]
332+
return module
333+
334+
311335
def _extract_namespace_imports(
312336
tree: "tree_sitter.Tree",
313337
source: bytes,
@@ -3670,7 +3694,32 @@ def _extract_edges(
36703694
confidence=edge_confidence,
36713695
)
36723696
edges.append(edge)
3697+
elif (named_imports or {}).get(func_name):
3698+
# WI-banaf: when a named-imported function is
3699+
# called but doesn't resolve to an intra-repo
3700+
# symbol (the common case for Node/browser
3701+
# built-ins like ``existsSync`` from ``node:fs``),
3702+
# emit an unresolved-call edge with the import
3703+
# path as the module hint. The io-boundaries
3704+
# layer matches the callee name against the
3705+
# JavaScript catalog and tags the edge.
3706+
module_hint = _normalize_import_module_hint(
3707+
named_imports[func_name]
3708+
)
3709+
dst_id = f"{lang}:{module_hint}:0-0:{func_name}:unresolved"
3710+
edge = Edge.create(
3711+
src=current_function.id,
3712+
dst=dst_id,
3713+
edge_type="calls",
3714+
line=node.start_point[0] + 1 + line_offset,
3715+
origin=PASS_ID,
3716+
origin_run_id=run.execution_id,
3717+
evidence_type="ast_call_unresolved_import",
3718+
confidence=0.70,
3719+
)
3720+
edges.append(edge)
36733721

3722+
if callee is not None:
36743723
# Return type inference: if function has a return
36753724
# type annotation, track the variable's type
36763725
if callee.kind in ("function", "method"):

packages/hypergumbo-lang-mainstream/tests/test_js_ts.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,85 @@ def test_extracts_function_calls(self, tmp_path: Path) -> None:
266266
# main calls helper
267267
assert any("helper" in e.dst for e in call_edges)
268268

269+
def test_unresolved_call_to_named_import_emits_edge(self, tmp_path: Path) -> None:
270+
"""Bare-name calls to named imports emit unresolved call edges (WI-banaf).
271+
272+
Without these edges io-boundaries cannot match Node/browser I/O
273+
primitives in TypeScript projects (UAT 2026-04-13 BUG-09a). The
274+
destination ID encodes the import path as the module hint so the
275+
catalog lookup can disambiguate (``fs`` vs ``crypto`` etc.).
276+
"""
277+
pytest.importorskip("tree_sitter_typescript")
278+
from hypergumbo_lang_mainstream.js_ts import analyze_javascript
279+
280+
code = """
281+
import { existsSync, readFileSync } from 'node:fs';
282+
283+
export function loadConfig(path: string) {
284+
if (!existsSync(path)) return null;
285+
return readFileSync(path, 'utf8');
286+
}
287+
"""
288+
(tmp_path / "app.ts").write_text(code)
289+
result = analyze_javascript(tmp_path)
290+
291+
unresolved = [
292+
e for e in result.edges
293+
if e.edge_type == "calls" and ":unresolved" in e.dst
294+
]
295+
callees = {e.dst for e in unresolved}
296+
assert any("existsSync" in c for c in callees), callees
297+
assert any("readFileSync" in c for c in callees), callees
298+
# Module hint encoded in dst (second colon-separated segment).
299+
for c in callees:
300+
if "existsSync" in c or "readFileSync" in c:
301+
# typescript:<module>:0-0:<name>:unresolved
302+
segs = c.split(":")
303+
# 'node:fs' contains a colon, so we expect either 'fs' or 'node'.
304+
# The module hint normalisation strips the 'node:' prefix.
305+
module = segs[1]
306+
assert module in ("fs", "node"), c
307+
308+
def test_resolved_call_does_not_emit_unresolved_edge(self, tmp_path: Path) -> None:
309+
"""When a callee resolves intra-repo we do NOT also emit an unresolved edge."""
310+
from hypergumbo_lang_mainstream.js_ts import analyze_javascript
311+
312+
code = """
313+
function helper() { return 1; }
314+
function main() { helper(); }
315+
"""
316+
(tmp_path / "app.js").write_text(code)
317+
result = analyze_javascript(tmp_path)
318+
319+
unresolved = [
320+
e for e in result.edges
321+
if e.edge_type == "calls" and ":unresolved" in e.dst
322+
]
323+
assert all("helper" not in e.dst for e in unresolved)
324+
325+
def test_unresolved_skips_unimported_names(self, tmp_path: Path) -> None:
326+
"""Calls to unknown bare names without an import do not produce noise.
327+
328+
Only names that appear in the file's named-import map become
329+
unresolved edges. This bounds the noise to legitimate import sites
330+
and keeps the catalog match precise.
331+
"""
332+
from hypergumbo_lang_mainstream.js_ts import analyze_javascript
333+
334+
code = """
335+
function main() {
336+
somethingNobodyImported();
337+
}
338+
"""
339+
(tmp_path / "app.js").write_text(code)
340+
result = analyze_javascript(tmp_path)
341+
342+
unresolved = [
343+
e for e in result.edges
344+
if e.edge_type == "calls" and ":unresolved" in e.dst
345+
]
346+
assert all("somethingNobodyImported" not in e.dst for e in unresolved)
347+
269348
def test_typescript_with_types(self, tmp_path: Path) -> None:
270349
"""Handles TypeScript files with type annotations."""
271350
pytest.importorskip("tree_sitter_typescript")

0 commit comments

Comments
 (0)