Skip to main content

code_moniker_core/lang/ts/
mod.rs

1use tree_sitter::{Language, Parser, Tree};
2
3use crate::core::code_graph::CodeGraph;
4use crate::core::moniker::Moniker;
5use crate::core::shape::Shape;
6
7use crate::lang::{ExtractionContext, KindSpec, LangExtractor, ParsedDocument};
8
9pub mod build;
10mod kinds;
11mod sdk_catalog;
12mod sdk_pipeline;
13
14pub use sdk_catalog::{TsSdkMember, TsSdkProfile};
15
16pub fn parse(source: &str) -> Tree {
17	parse_with_uri(source, "")
18}
19
20pub fn parse_with_uri(source: &str, uri: &str) -> Tree {
21	let mut parser = Parser::new();
22	let language: Language = if uri_uses_jsx(uri) {
23		tree_sitter_typescript::LANGUAGE_TSX.into()
24	} else {
25		tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
26	};
27	parser.set_language(&language).unwrap_or_else(|err| {
28		panic!("failed to load tree-sitter TypeScript grammar: {err}");
29	});
30	parser.parse(source, None).unwrap_or_else(|| {
31		panic!("tree-sitter parse returned None on a non-cancelled call");
32	})
33}
34
35fn uri_uses_jsx(uri: &str) -> bool {
36	uri.ends_with(".tsx") || uri.ends_with(".jsx")
37}
38
39#[derive(Clone, Debug, Default)]
40pub struct Presets {
41	pub di_register_callees: Vec<String>,
42	pub path_aliases: Vec<PathAlias>,
43	pub sdk_profile: TsSdkProfile,
44}
45
46#[derive(Clone, Debug)]
47pub struct PathAlias {
48	pub pattern: String,
49	pub substitution: String,
50}
51
52pub fn extract(
53	uri: &str,
54	source: &str,
55	anchor: &Moniker,
56	deep: bool,
57	presets: &Presets,
58) -> CodeGraph {
59	<Lang as LangExtractor>::extract(uri, source, anchor, deep, presets)
60}
61
62pub struct Lang;
63
64const DEF_KINDS: &[&str] = &[
65	"class",
66	"interface",
67	"type",
68	"function",
69	"method",
70	"const",
71	"enum",
72	"constructor",
73	"field",
74	"enum_constant",
75	"namespace",
76];
77
78const DEF_KIND_SPECS: &[KindSpec] = &[
79	KindSpec::new("namespace", Shape::Namespace, 10, "namespace"),
80	KindSpec::new("class", Shape::Type, 20, "class"),
81	KindSpec::new("interface", Shape::Type, 21, "interface"),
82	KindSpec::new("enum", Shape::Type, 22, "enum"),
83	KindSpec::new("type", Shape::Type, 23, "type"),
84	KindSpec::new("constructor", Shape::Callable, 40, "constructor"),
85	KindSpec::new("method", Shape::Callable, 41, "method"),
86	KindSpec::new("function", Shape::Callable, 42, "function"),
87	KindSpec::new("field", Shape::Value, 60, "field"),
88	KindSpec::new("enum_constant", Shape::Value, 61, "enum_constant"),
89	KindSpec::new("const", Shape::Value, 62, "const"),
90];
91
92impl crate::lang::LangExtractor for Lang {
93	type Presets = Presets;
94	const LANG_TAG: &'static str = "ts";
95	const ALLOWED_KINDS: &'static [&'static str] = DEF_KINDS;
96	const KIND_SPECS: &'static [KindSpec] = DEF_KIND_SPECS;
97	const ALLOWED_VISIBILITIES: &'static [&'static str] =
98		&["public", "private", "protected", "module"];
99
100	fn parse(uri: &str, source: &str) -> ParsedDocument {
101		ParsedDocument::new(parse_with_uri(source, uri))
102	}
103
104	fn file_root(uri: &str, anchor: &Moniker) -> Option<Moniker> {
105		Some(sdk_pipeline::compute_module_moniker(anchor, uri))
106	}
107
108	fn extract_parsed(
109		context: ExtractionContext<'_, Self::Presets>,
110		document: &ParsedDocument,
111	) -> CodeGraph {
112		sdk_pipeline::extract(
113			context.uri,
114			context.source,
115			document,
116			context.anchor,
117			context.deep,
118			context.presets,
119		)
120	}
121}
122
123#[cfg(test)]
124mod tests {
125	use super::*;
126	use crate::core::moniker::MonikerBuilder;
127	use crate::lang::assert_conformance;
128
129	fn extract(uri: &str, source: &str, anchor: &Moniker, deep: bool) -> CodeGraph {
130		let g = super::extract(uri, source, anchor, deep, &Presets::default());
131		assert_conformance::<super::Lang>(&g, anchor);
132		g
133	}
134
135	fn make_anchor() -> Moniker {
136		MonikerBuilder::new()
137			.project(b"my-app")
138			.segment(b"path", b"main")
139			.build()
140	}
141
142	#[test]
143	fn parse_empty_source_returns_program() {
144		let tree = parse("");
145		assert_eq!(tree.root_node().kind(), "program");
146		assert_eq!(tree.root_node().child_count(), 0);
147	}
148
149	#[test]
150	fn parse_simple_class_has_class_declaration() {
151		let tree = parse("class Foo {}");
152		assert_eq!(
153			tree.root_node().child(0).unwrap().kind(),
154			"class_declaration"
155		);
156	}
157
158	#[test]
159	fn parse_invalid_syntax_marks_errors() {
160		assert!(parse("class { ").root_node().has_error());
161	}
162
163	#[test]
164	fn extract_strips_each_known_extension() {
165		let anchor = make_anchor();
166		for uri in [
167			"foo.ts", "foo.tsx", "foo.js", "foo.jsx", "foo.mjs", "foo.cjs",
168		] {
169			let g = extract(uri, "", &anchor, false);
170			let last = g.root().as_view().segments().last().unwrap();
171			assert_eq!(last.name, b"foo", "extension not stripped on {uri}");
172		}
173	}
174
175	#[test]
176	fn extract_dot_only_specifier_resolves_relative_not_external() {
177		let g = extract(
178			"src/__tests__/foo.test.ts",
179			"import { z } from \"..\";",
180			&make_anchor(),
181			false,
182		);
183		let r = g.refs().next().unwrap();
184		let target = MonikerBuilder::new()
185			.project(b"my-app")
186			.segment(b"path", b"main")
187			.segment(b"lang", b"ts")
188			.segment(b"dir", b"src")
189			.segment(b"path", b"z")
190			.build();
191		assert_eq!(r.target, target);
192	}
193
194	#[test]
195	fn extract_dotdot_import_walks_up_then_down() {
196		let g = extract(
197			"src/lib/foo.ts",
198			"import { X } from '../other';",
199			&make_anchor(),
200			false,
201		);
202		let r = g.refs().next().unwrap();
203		let target = MonikerBuilder::new()
204			.project(b"my-app")
205			.segment(b"path", b"main")
206			.segment(b"lang", b"ts")
207			.segment(b"dir", b"src")
208			.segment(b"module", b"other")
209			.segment(b"path", b"X")
210			.build();
211		assert_eq!(r.target, target);
212	}
213
214	#[test]
215	fn extract_call_to_nested_function_is_resolved() {
216		let src = r#"
217function outer() {
218    function inner() {}
219    inner();
220}
221"#;
222		let g = extract("util.ts", src, &make_anchor(), false);
223		let r = g
224			.refs()
225			.find(|r| {
226				r.kind == b"calls"
227					&& r.target
228						.as_view()
229						.segments()
230						.last()
231						.unwrap()
232						.name
233						.starts_with(b"inner")
234			})
235			.expect("calls ref for inner");
236		assert_eq!(
237			r.confidence,
238			b"resolved",
239			"call to nested fn must be resolved; got {:?}",
240			std::str::from_utf8(&r.confidence)
241		);
242		let segs: Vec<_> = r.target.as_view().segments().collect();
243		assert!(
244			segs.iter()
245				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
246			"target must be scoped under outer; got {:?}",
247			segs.iter()
248				.map(|s| (
249					std::str::from_utf8(s.kind).unwrap_or("?"),
250					std::str::from_utf8(s.name).unwrap_or("?")
251				))
252				.collect::<Vec<_>>()
253		);
254	}
255
256	#[test]
257	fn extract_call_hoists_nested_fn_used_before_decl() {
258		let src = r#"
259function outer() {
260    inner();
261    function inner() {}
262}
263"#;
264		let g = extract("util.ts", src, &make_anchor(), false);
265		let r = g
266			.refs()
267			.find(|r| {
268				r.kind == b"calls"
269					&& r.target
270						.as_view()
271						.segments()
272						.last()
273						.unwrap()
274						.name
275						.starts_with(b"inner")
276			})
277			.expect("calls ref for inner");
278		assert_eq!(
279			r.confidence,
280			b"resolved",
281			"hoisted nested fn call must be resolved; got {:?}",
282			std::str::from_utf8(&r.confidence)
283		);
284	}
285
286	#[test]
287	fn extract_reads_param_marks_confidence_local() {
288		let g = extract(
289			"util.ts",
290			"function f(x) { return x; }",
291			&make_anchor(),
292			true,
293		);
294		let r = g.refs().find(|r| r.kind == b"reads").expect("reads ref");
295		assert_eq!(r.confidence, b"local".to_vec(), "ref to a param is local");
296	}
297
298	#[test]
299	fn extract_calls_local_function_marks_confidence_local() {
300		let g = extract(
301			"util.ts",
302			"function f() { const helper = () => 1; helper(); }",
303			&make_anchor(),
304			true,
305		);
306		let r = g.refs().find(|r| r.kind == b"calls").expect("calls ref");
307		assert_eq!(
308			r.confidence,
309			b"local".to_vec(),
310			"call into a locally-bound name is local"
311		);
312	}
313
314	#[test]
315	fn extract_local_def_has_no_visibility() {
316		let g = extract(
317			"util.ts",
318			"function f() { let x = 1; }",
319			&make_anchor(),
320			true,
321		);
322		let local = g.defs().find(|d| d.kind == b"local").expect("local def");
323		assert!(
324			local.visibility.is_empty(),
325			"locals must not carry a synthetic visibility, got {:?}",
326			String::from_utf8_lossy(&local.visibility)
327		);
328	}
329
330	#[test]
331	fn extract_param_def_has_no_visibility() {
332		let g = extract("util.ts", "function f(x) {}", &make_anchor(), true);
333		let p = g.defs().find(|d| d.kind == b"param").expect("param def");
334		assert!(p.visibility.is_empty());
335	}
336
337	#[test]
338	fn extract_di_register_fires_only_when_callee_in_preset() {
339		let presets = Presets {
340			di_register_callees: vec!["register".into(), "bind".into()],
341			..Presets::default()
342		};
343		let g = super::extract(
344			"util.ts",
345			"register(UserService);",
346			&make_anchor(),
347			false,
348			&presets,
349		);
350		assert!(g.refs().any(|r| r.kind == b"di_register"));
351	}
352
353	#[test]
354	fn extract_di_register_silent_without_preset() {
355		let g = extract("util.ts", "register(UserService);", &make_anchor(), false);
356		assert!(
357			g.refs().all(|r| r.kind != b"di_register"),
358			"di_register must stay silent without a preset",
359		);
360	}
361
362	#[test]
363	fn extract_di_register_skips_non_matching_callee() {
364		let presets = Presets {
365			di_register_callees: vec!["register".into()],
366			..Presets::default()
367		};
368		let g = super::extract("util.ts", "expect(value);", &make_anchor(), false, &presets);
369		assert!(g.refs().all(|r| r.kind != b"di_register"));
370	}
371
372	#[test]
373	fn extract_di_register_register_with_name_and_factory() {
374		let presets = Presets {
375			di_register_callees: vec!["register".into()],
376			..Presets::default()
377		};
378		let g = super::extract(
379			"util.ts",
380			"register('repoStore', makeRepoStore);",
381			&make_anchor(),
382			false,
383			&presets,
384		);
385		assert!(
386			g.refs().any(|r| r.kind == b"di_register"),
387			"register('name', factory) must emit di_register on the factory identifier",
388		);
389	}
390
391	#[test]
392	fn extract_di_register_member_callee_register() {
393		let presets = Presets {
394			di_register_callees: vec!["register".into()],
395			..Presets::default()
396		};
397		let g = super::extract(
398			"util.ts",
399			"container.register('repoStore', makeRepoStore);",
400			&make_anchor(),
401			false,
402			&presets,
403		);
404		assert!(
405			g.refs().any(|r| r.kind == b"di_register"),
406			"container.register(...) must emit di_register when 'register' is in the preset",
407		);
408	}
409
410	#[test]
411	fn extract_di_register_recurses_into_factory_call_argument() {
412		let presets = Presets {
413			di_register_callees: vec!["register".into()],
414			..Presets::default()
415		};
416		let g = super::extract(
417			"util.ts",
418			"register('repoStore', asFunction(makeRepoStore));",
419			&make_anchor(),
420			false,
421			&presets,
422		);
423		assert!(
424			g.refs().any(|r| r.kind == b"di_register"),
425			"register('name', asFunction(make)) must recurse to find 'make'",
426		);
427	}
428
429	#[test]
430	fn extract_di_register_recurses_through_chained_call_postfix() {
431		let presets = Presets {
432			di_register_callees: vec!["asFunction".into()],
433			..Presets::default()
434		};
435		let g = super::extract(
436			"util.ts",
437			"asFunction(makeRepoStore).singleton();",
438			&make_anchor(),
439			false,
440			&presets,
441		);
442		assert!(
443			g.refs().any(|r| r.kind == b"di_register"),
444			"asFunction(make).singleton() chain must still register the inner 'make'",
445		);
446	}
447
448	#[test]
449	fn extract_di_register_full_awilix_pattern() {
450		let presets = Presets {
451			di_register_callees: vec!["register".into()],
452			..Presets::default()
453		};
454		let g = super::extract(
455			"util.ts",
456			"container.register('readResource', asFunction(makeReadResource).singleton());",
457			&make_anchor(),
458			false,
459			&presets,
460		);
461		assert!(
462			g.refs().any(|r| r.kind == b"di_register"),
463			"container.register('name', asFunction(make).singleton()) must emit di_register",
464		);
465	}
466	#[test]
467	fn extract_shallow_skips_param_and_local() {
468		let g = extract(
469			"util.ts",
470			"function f(a: number) { let x = 1; }",
471			&make_anchor(),
472			false,
473		);
474		assert!(
475			g.defs().all(|d| d.kind != b"param" && d.kind != b"local"),
476			"shallow extraction must not produce param/local defs"
477		);
478	}
479
480	#[test]
481	fn extract_deep_emits_params_and_locals() {
482		let g = extract(
483			"util.ts",
484			"function f(a: number, b: number) { let sum = a + b; }",
485			&make_anchor(),
486			true,
487		);
488		let pa = MonikerBuilder::new()
489			.project(b"my-app")
490			.segment(b"path", b"main")
491			.segment(b"lang", b"ts")
492			.segment(b"module", b"util")
493			.segment(b"function", b"f(a:number,b:number)")
494			.segment(b"param", b"a")
495			.build();
496		let pb = MonikerBuilder::new()
497			.project(b"my-app")
498			.segment(b"path", b"main")
499			.segment(b"lang", b"ts")
500			.segment(b"module", b"util")
501			.segment(b"function", b"f(a:number,b:number)")
502			.segment(b"param", b"b")
503			.build();
504		let sum = MonikerBuilder::new()
505			.project(b"my-app")
506			.segment(b"path", b"main")
507			.segment(b"lang", b"ts")
508			.segment(b"module", b"util")
509			.segment(b"function", b"f(a:number,b:number)")
510			.segment(b"local", b"sum")
511			.build();
512		assert!(
513			g.contains(&pa),
514			"missing param a; defs: {:?}",
515			g.def_monikers()
516		);
517		assert!(g.contains(&pb));
518		assert!(g.contains(&sum));
519	}
520
521	#[test]
522	fn extract_deep_anonymous_callback_uses_position_name() {
523		let g = extract(
524			"util.ts",
525			"function f() { [1].map(x => x); }",
526			&make_anchor(),
527			true,
528		);
529		let monikers = g.def_monikers();
530		let cb = monikers
531			.iter()
532			.find(|m| {
533				let last = m.as_view().segments().last().unwrap();
534				last.kind == b"function" && last.name.starts_with(b"__cb_")
535			})
536			.expect("anonymous callback def with __cb_ prefix")
537			.clone();
538		let view = cb.as_view();
539		let last = view.segments().last().unwrap();
540		assert_eq!(last.kind, b"function");
541		assert!(g.defs().any(|d| {
542			let dv = d.moniker.as_view();
543			dv.segment_count() == view.segment_count() + 1
544				&& dv.segments().last().unwrap().kind == b"param"
545		}));
546	}
547
548	#[test]
549	fn extract_alias_import_routes_to_project_rooted_module() {
550		let presets = Presets {
551			path_aliases: vec![PathAlias {
552				pattern: "@/*".into(),
553				substitution: "./src/*".into(),
554			}],
555			..Presets::default()
556		};
557		let g = super::extract(
558			"src/router.tsx",
559			"import { AppShell } from '@/components/layout/app-shell';",
560			&make_anchor(),
561			false,
562			&presets,
563		);
564		let r = g.refs().next().expect("one ref");
565		let target = MonikerBuilder::new()
566			.project(b"my-app")
567			.segment(b"path", b"main")
568			.segment(b"lang", b"ts")
569			.segment(b"dir", b"src")
570			.segment(b"dir", b"components")
571			.segment(b"dir", b"layout")
572			.segment(b"module", b"app-shell")
573			.segment(b"path", b"AppShell")
574			.build();
575		assert_eq!(
576			r.target, target,
577			"alias-resolved import must point at the project-rooted module, not external_pkg",
578		);
579	}
580
581	#[test]
582	fn extract_alias_import_keeps_external_when_no_alias_matches() {
583		let presets = Presets {
584			path_aliases: vec![PathAlias {
585				pattern: "@/*".into(),
586				substitution: "./src/*".into(),
587			}],
588			..Presets::default()
589		};
590		let g = super::extract(
591			"util.ts",
592			"import { join } from '@scope/pkg/sub';",
593			&make_anchor(),
594			false,
595			&presets,
596		);
597		let r = g.refs().next().unwrap();
598		let head = r.target.as_view().segments().next().unwrap();
599		assert_eq!(head.kind, b"external_pkg");
600	}
601
602	#[test]
603	fn extract_unknown_member_call_does_not_claim_sdk_provenance() {
604		let g = extract(
605			"app.ts",
606			"function read(store: Store) { return store.getState(); }",
607			&make_anchor(),
608			false,
609		);
610		let call = g
611			.refs()
612			.find(|reference| {
613				reference.kind == b"method_call"
614					&& reference
615						.target
616						.as_view()
617						.segments()
618						.last()
619						.is_some_and(|segment| segment.name == b"getState")
620			})
621			.expect("getState method call");
622		let first = call.target.as_view().segments().next().unwrap();
623		assert_ne!(first.kind, b"sdk");
624		assert_eq!(call.confidence.as_ref(), b"name_match");
625	}
626
627	#[test]
628	fn extract_global_member_call_preserves_runtime_owner() {
629		let g = extract(
630			"app.ts",
631			"function read() { return Object.fromEntries([]); }",
632			&make_anchor(),
633			false,
634		);
635		let call = g
636			.refs()
637			.find(|reference| reference.kind == b"method_call")
638			.expect("Object.fromEntries method call");
639		let segments = call.target.as_view().segments().collect::<Vec<_>>();
640		assert_eq!(
641			segments
642				.iter()
643				.map(|segment| (segment.kind, segment.name))
644				.collect::<Vec<_>>(),
645			vec![
646				(b"sdk".as_slice(), b"ts".as_slice()),
647				(b"path".as_slice(), b"runtime".as_slice()),
648				(b"path".as_slice(), b"Object".as_slice()),
649				(b"method".as_slice(), b"fromEntries".as_slice()),
650			]
651		);
652	}
653
654	#[test]
655	fn extract_dom_profile_owns_globals_types_and_typed_member_chains() {
656		let presets = Presets {
657			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
658			..Presets::default()
659		};
660		let graph = super::extract(
661			"app.ts",
662			"function makeButton(button: HTMLButtonElement): HTMLButtonElement { return button; }
663			function render(button: HTMLButtonElement) {
664				document.createElement('div');
665				button.replaceChildren();
666				button.classList.add('active');
667				const created = document.createElement('button');
668				created.replaceChildren();
669				created.classList.add('created');
670				const fromHelper = makeButton(button);
671				fromHelper.classList.add('helper');
672				getComputedStyle(button);
673			}",
674			&make_anchor(),
675			true,
676			&presets,
677		);
678
679		for name in [
680			b"HTMLButtonElement".as_slice(),
681			b"createElement".as_slice(),
682			b"replaceChildren".as_slice(),
683			b"add".as_slice(),
684			b"getComputedStyle".as_slice(),
685		] {
686			assert!(
687				graph.refs().any(|reference| {
688					reference
689						.target
690						.as_view()
691						.segments()
692						.last()
693						.is_some_and(|segment| segment.name == name)
694						&& reference
695							.target
696							.as_view()
697							.segments()
698							.next()
699							.is_some_and(|segment| segment.kind == b"sdk")
700				}),
701				"{:?} must resolve through the active DOM SDK profile",
702				String::from_utf8_lossy(name),
703			);
704		}
705		let sdk_add_calls = graph
706			.refs()
707			.filter(|reference| {
708				reference.kind == b"method_call"
709					&& reference
710						.target
711						.as_view()
712						.segments()
713						.last()
714						.is_some_and(|segment| segment.name == b"add")
715					&& reference
716						.target
717						.as_view()
718						.segments()
719						.next()
720						.is_some_and(|segment| segment.kind == b"sdk")
721			})
722			.count();
723		assert_eq!(
724			sdk_add_calls, 3,
725			"typed params, SDK call results and local typed helper results must preserve DOM ownership",
726		);
727	}
728
729	#[test]
730	fn extract_es_only_profile_does_not_promote_dom_or_react() {
731		let presets = Presets {
732			sdk_profile: TsSdkProfile::from_libraries(["ES2022"]),
733			..Presets::default()
734		};
735		let graph = super::extract(
736			"server.ts",
737			"function run(node: HTMLElement, React: unknown) {
738				document.createElement('div');
739				node.replaceChildren();
740				React.createElement('div');
741				return Promise.resolve(node);
742			}",
743			&make_anchor(),
744			true,
745			&presets,
746		);
747
748		for name in [
749			b"document".as_slice(),
750			b"HTMLElement".as_slice(),
751			b"replaceChildren".as_slice(),
752			b"React".as_slice(),
753		] {
754			assert!(
755				graph
756					.refs()
757					.filter(|reference| {
758						reference
759							.target
760							.as_view()
761							.segments()
762							.last()
763							.is_some_and(|segment| segment.name == name)
764					})
765					.all(|reference| reference
766						.target
767						.as_view()
768						.segments()
769						.next()
770						.is_none_or(|segment| segment.kind != b"sdk")),
771				"{:?} must not be promoted by an ES-only profile",
772				String::from_utf8_lossy(name),
773			);
774		}
775		assert!(graph.refs().any(|reference| {
776			reference
777				.target
778				.as_view()
779				.segments()
780				.last()
781				.is_some_and(|segment| segment.name == b"resolve")
782				&& reference
783					.target
784					.as_view()
785					.segments()
786					.next()
787					.is_some_and(|segment| segment.kind == b"sdk")
788		}));
789	}
790
791	#[test]
792	fn extract_imported_type_shadows_same_named_dom_type_for_member_calls() {
793		let presets = Presets {
794			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
795			..Presets::default()
796		};
797		let graph = super::extract(
798			"server.ts",
799			"import type { Response } from 'express';
800			function send(res: Response) { res.json({ ok: true }); }",
801			&make_anchor(),
802			true,
803			&presets,
804		);
805		let call = graph
806			.refs()
807			.find(|reference| {
808				reference.kind == b"method_call"
809					&& reference
810						.target
811						.as_view()
812						.segments()
813						.last()
814						.is_some_and(|segment| segment.name == b"json")
815			})
816			.expect("res.json method call");
817
818		assert!(
819			call.target
820				.as_view()
821				.segments()
822				.next()
823				.is_none_or(|segment| segment.kind != b"sdk"),
824			"the imported Express Response must shadow the DOM Response catalog entry",
825		);
826	}
827
828	#[test]
829	fn extract_callable_local_type_shadows_same_named_dom_type() {
830		let presets = Presets {
831			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
832			..Presets::default()
833		};
834		let graph = super::extract(
835			"server.ts",
836			"interface CustomResponse { json(): void }
837			function send() {
838				type Response = CustomResponse;
839				const response: Response = {} as Response;
840				response.json();
841			}",
842			&make_anchor(),
843			true,
844			&presets,
845		);
846		let call = graph
847			.refs()
848			.find(|reference| {
849				reference.kind == b"method_call" && reference.call_name.as_ref() == b"json"
850			})
851			.expect("response.json method call");
852		assert!(
853			call.target
854				.as_view()
855				.segments()
856				.next()
857				.is_none_or(|segment| segment.kind != b"sdk"),
858			"a callable-local Response alias must shadow the DOM Response",
859		);
860	}
861
862	#[test]
863	fn extract_hoists_callable_local_type_shadow_before_declaration() {
864		let presets = Presets {
865			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
866			..Presets::default()
867		};
868		let graph = super::extract(
869			"server.ts",
870			"interface CustomResponse { json(): void }
871			function send() {
872				const response: Response = {} as Response;
873				response.json();
874				type Response = CustomResponse;
875			}",
876			&make_anchor(),
877			true,
878			&presets,
879		);
880		let call = graph
881			.refs()
882			.find(|reference| {
883				reference.kind == b"method_call" && reference.call_name.as_ref() == b"json"
884			})
885			.expect("response.json method call");
886		assert!(
887			call.target
888				.as_view()
889				.segments()
890				.next()
891				.is_none_or(|segment| segment.kind != b"sdk"),
892			"callable-local type declarations must shadow the SDK before their declaration",
893		);
894	}
895
896	#[test]
897	fn extract_destructured_parameter_does_not_share_one_sdk_type_between_leaves() {
898		let presets = Presets {
899			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
900			..Presets::default()
901		};
902		let graph = super::extract(
903			"app.ts",
904			"function render(
905				{ button, label }: { button: HTMLButtonElement; label: string }
906			) {
907				button.replaceChildren();
908				label.replaceChildren();
909			}",
910			&make_anchor(),
911			true,
912			&presets,
913		);
914		let sdk_calls = graph
915			.refs()
916			.filter(|reference| {
917				reference.kind == b"method_call"
918					&& reference.call_name.as_ref() == b"replaceChildren"
919					&& reference
920						.target
921						.as_view()
922						.segments()
923						.next()
924						.is_some_and(|segment| segment.kind == b"sdk")
925			})
926			.count();
927		assert_eq!(
928			sdk_calls, 1,
929			"button must keep its DOM type without assigning HTMLButtonElement to label",
930		);
931	}
932
933	#[test]
934	fn extract_module_typed_binding_preserves_sdk_owner_inside_callables() {
935		let presets = Presets {
936			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
937			..Presets::default()
938		};
939		let graph = super::extract(
940			"app.ts",
941			"const button: HTMLButtonElement = document.createElement('button');
942			function render() { button.replaceChildren(); }",
943			&make_anchor(),
944			true,
945			&presets,
946		);
947		let call = graph
948			.refs()
949			.find(|reference| {
950				reference.kind == b"method_call"
951					&& reference.call_name.as_ref() == b"replaceChildren"
952			})
953			.expect("button.replaceChildren method call");
954		assert_eq!(
955			call.target.as_view().segments().next().unwrap().kind,
956			b"sdk",
957			"an explicitly typed module binding must retain its DOM owner",
958		);
959	}
960
961	#[test]
962	fn extract_imported_type_shadows_same_named_dom_type_for_module_binding() {
963		let presets = Presets {
964			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "DOM"]),
965			..Presets::default()
966		};
967		let graph = super::extract(
968			"server.ts",
969			"import type { Response } from 'express';
970			const response: Response = {} as Response;
971			export function send() { response.json(); }",
972			&make_anchor(),
973			true,
974			&presets,
975		);
976		let call = graph
977			.refs()
978			.find(|reference| {
979				reference.kind == b"method_call" && reference.call_name.as_ref() == b"json"
980			})
981			.expect("response.json method call");
982		assert!(
983			call.target
984				.as_view()
985				.segments()
986				.next()
987				.is_none_or(|segment| segment.kind != b"sdk"),
988			"an imported module binding type must shadow the DOM catalog entry",
989		);
990	}
991
992	#[test]
993	fn extract_namespace_constructor_uses_sdk_catalog() {
994		let presets = Presets {
995			sdk_profile: TsSdkProfile::from_libraries(["ES2022"]),
996			..Presets::default()
997		};
998		let graph = super::extract(
999			"app.ts",
1000			"const formatter = Intl.DateTimeFormat('fr-FR');",
1001			&make_anchor(),
1002			true,
1003			&presets,
1004		);
1005		let call = graph
1006			.refs()
1007			.find(|reference| {
1008				reference.kind == b"method_call"
1009					&& reference.call_name.as_ref() == b"DateTimeFormat"
1010			})
1011			.expect("Intl.DateTimeFormat call");
1012		assert_eq!(
1013			call.target.as_view().segments().next().unwrap().kind,
1014			b"sdk",
1015			"declare namespace members must be represented by the generated SDK catalog",
1016		);
1017	}
1018
1019	#[test]
1020	fn extract_webworker_profile_owns_worker_without_dom() {
1021		let presets = Presets {
1022			sdk_profile: TsSdkProfile::from_libraries(["ES2022", "WebWorker"]),
1023			..Presets::default()
1024		};
1025		let graph = super::extract(
1026			"worker.ts",
1027			"postMessage('ready'); document.createElement('div');",
1028			&make_anchor(),
1029			true,
1030			&presets,
1031		);
1032
1033		assert!(graph.refs().any(|reference| {
1034			reference.call_name.as_ref() == b"postMessage"
1035				&& reference
1036					.target
1037					.as_view()
1038					.segments()
1039					.next()
1040					.is_some_and(|segment| segment.kind == b"sdk")
1041		}));
1042		assert!(
1043			graph
1044				.refs()
1045				.filter(|reference| {
1046					reference
1047						.target
1048						.as_view()
1049						.segments()
1050						.last()
1051						.is_some_and(|segment| segment.name == b"document")
1052				})
1053				.all(|reference| reference
1054					.target
1055					.as_view()
1056					.segments()
1057					.next()
1058					.is_none_or(|segment| segment.kind != b"sdk"))
1059		);
1060	}
1061
1062	#[test]
1063	fn extract_local_binding_shadows_runtime_global_for_member_calls() {
1064		let g = extract(
1065			"app.ts",
1066			"interface Logger { flush(): void } function run(console: Logger) { console.flush(); }",
1067			&make_anchor(),
1068			true,
1069		);
1070		let call = g
1071			.refs()
1072			.find(|reference| {
1073				reference.kind == b"method_call" && reference.call_name.as_ref() == b"flush"
1074			})
1075			.expect("console.flush method call");
1076		assert_ne!(
1077			call.target.as_view().segments().next().unwrap().kind,
1078			b"sdk",
1079			"a lexical binding named like a runtime global must win",
1080		);
1081	}
1082
1083	#[test]
1084	fn extract_module_binding_shadows_runtime_global_for_member_calls() {
1085		let g = extract(
1086			"app.ts",
1087			"const console = { flush() {} }; export function run() { console.flush(); }",
1088			&make_anchor(),
1089			true,
1090		);
1091		let call = g
1092			.refs()
1093			.find(|reference| {
1094				reference.kind == b"method_call" && reference.call_name.as_ref() == b"flush"
1095			})
1096			.expect("console.flush method call");
1097		assert_ne!(
1098			call.target.as_view().segments().next().unwrap().kind,
1099			b"sdk",
1100			"a module binding named like a runtime global must win",
1101		);
1102		let segments = call.target.as_view().segments().collect::<Vec<_>>();
1103		assert!(
1104			segments.windows(2).any(|pair| {
1105				pair[0].kind == b"const"
1106					&& pair[0].name == b"console"
1107					&& pair[1].kind == b"method"
1108					&& pair[1].name == b"flush"
1109			}),
1110			"unexpected target {segments:?}",
1111		);
1112	}
1113
1114	#[test]
1115	fn extract_jsx_expression_identifier_still_emits_read() {
1116		let g = extract(
1117			"app.tsx",
1118			"function App(label: string) { return <div>{label}</div>; }",
1119			&make_anchor(),
1120			true,
1121		);
1122		assert!(
1123			g.refs().any(|r| r.kind == b"reads"
1124				&& r.target.as_view().segments().last().unwrap().name == b"label"),
1125			"identifier inside jsx_expression must still surface as a read",
1126		);
1127	}
1128
1129	#[test]
1130	fn extract_closure_read_targets_outer_param_def() {
1131		let src = "function outer({ x }: { x: string }) { return function inner() { return x; }; }";
1132		let g = extract("util.ts", src, &make_anchor(), true);
1133		let read = g
1134			.refs()
1135			.find(|r| {
1136				r.kind == b"reads" && r.target.as_view().segments().last().unwrap().name == b"x"
1137			})
1138			.expect("reads ref for x");
1139		let segs: Vec<_> = read.target.as_view().segments().collect();
1140		assert!(
1141			segs.iter().any(|s| s.kind == b"param" && s.name == b"x"),
1142			"target must terminate with param:x of the defining frame, got: {segs:?}"
1143		);
1144		assert!(
1145			!segs
1146				.iter()
1147				.any(|s| s.kind == b"function" && s.name == b"inner()"),
1148			"target must NOT carry the inner frame segment, got: {segs:?}"
1149		);
1150	}
1151
1152	#[test]
1153	fn extract_closure_uses_type_targets_outer_type_alias_def() {
1154		let src = "function outer() { type Local = string; function inner(x: Local): Local { return x; } return inner; }";
1155		let g = extract("util.ts", src, &make_anchor(), true);
1156		let r = g
1157			.refs()
1158			.find(|r| {
1159				r.kind == b"uses_type"
1160					&& r.target.as_view().segments().last().unwrap().name == b"Local"
1161			})
1162			.expect("uses_type ref for Local");
1163		let segs: Vec<_> = r.target.as_view().segments().collect();
1164		assert!(
1165			segs.iter().any(|s| s.kind == b"type" && s.name == b"Local"),
1166			"target must terminate with type:Local of the defining frame, got: {segs:?}"
1167		);
1168		assert!(
1169			segs.iter()
1170				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
1171			"target must be parented under outer (the defining frame), got: {segs:?}"
1172		);
1173	}
1174
1175	#[test]
1176	fn extract_closure_uses_type_targets_outer_interface_def() {
1177		let src = "function outer() { interface Local { v: string; } function inner(x: Local): Local { return x; } return inner; }";
1178		let g = extract("util.ts", src, &make_anchor(), true);
1179		let r = g
1180			.refs()
1181			.find(|r| {
1182				r.kind == b"uses_type"
1183					&& r.target.as_view().segments().last().unwrap().name == b"Local"
1184			})
1185			.expect("uses_type ref for Local");
1186		let segs: Vec<_> = r.target.as_view().segments().collect();
1187		assert!(
1188			segs.iter()
1189				.any(|s| s.kind == b"interface" && s.name == b"Local"),
1190			"target must terminate with interface:Local of the defining frame, got: {segs:?}"
1191		);
1192		assert!(
1193			segs.iter()
1194				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
1195			"target must be parented under outer, got: {segs:?}"
1196		);
1197	}
1198
1199	#[test]
1200	fn extract_closure_instantiates_targets_outer_class_def() {
1201		let src = "function outer() { class Local { ok = true; } function inner() { return new Local(); } return inner; }";
1202		let g = extract("util.ts", src, &make_anchor(), true);
1203		let r = g
1204			.refs()
1205			.find(|r| {
1206				r.kind == b"instantiates"
1207					&& r.target.as_view().segments().last().unwrap().name == b"Local"
1208			})
1209			.expect("instantiates ref for Local");
1210		let segs: Vec<_> = r.target.as_view().segments().collect();
1211		assert!(
1212			segs.iter()
1213				.any(|s| s.kind == b"class" && s.name == b"Local"),
1214			"target must terminate with class:Local of the defining frame, got: {segs:?}"
1215		);
1216		assert!(
1217			segs.iter()
1218				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
1219			"target must be parented under outer, got: {segs:?}"
1220		);
1221	}
1222
1223	#[test]
1224	fn extract_closure_uses_type_targets_outer_enum_def() {
1225		let src = "function outer() { enum Mode { A, B } function inner(m: Mode): Mode { return m; } return inner; }";
1226		let g = extract("util.ts", src, &make_anchor(), true);
1227		let r = g
1228			.refs()
1229			.find(|r| {
1230				r.kind == b"uses_type"
1231					&& r.target.as_view().segments().last().unwrap().name == b"Mode"
1232			})
1233			.expect("uses_type ref for Mode");
1234		let segs: Vec<_> = r.target.as_view().segments().collect();
1235		assert!(
1236			segs.iter().any(|s| s.kind == b"enum" && s.name == b"Mode"),
1237			"target must terminate with enum:Mode of the defining frame, got: {segs:?}"
1238		);
1239		assert!(
1240			segs.iter()
1241				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
1242			"target must be parented under outer, got: {segs:?}"
1243		);
1244	}
1245
1246	#[test]
1247	fn extract_closure_call_targets_outer_local_def() {
1248		let src = "function outer() { const helper = () => 1; return function inner() { return helper(); }; }";
1249		let g = extract("util.ts", src, &make_anchor(), true);
1250		let call = g
1251			.refs()
1252			.find(|r| {
1253				r.kind == b"calls"
1254					&& r.target.as_view().segments().last().unwrap().name == b"helper"
1255			})
1256			.expect("calls ref for helper");
1257		let segs: Vec<_> = call.target.as_view().segments().collect();
1258		assert!(
1259			segs.iter()
1260				.any(|s| s.kind == b"local" && s.name == b"helper"),
1261			"target must terminate with local:helper of the defining frame, got: {segs:?}"
1262		);
1263		assert!(
1264			!segs
1265				.iter()
1266				.any(|s| s.kind == b"function" && s.name == b"inner()"),
1267			"target must NOT carry the inner frame segment, got: {segs:?}"
1268		);
1269	}
1270}