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::KindSpec;
8
9pub mod build;
10mod kinds;
11mod sdk_pipeline;
12
13pub fn parse(source: &str) -> Tree {
14	parse_with_uri(source, "")
15}
16
17pub fn parse_with_uri(source: &str, uri: &str) -> Tree {
18	let mut parser = Parser::new();
19	let language: Language = if uri_uses_jsx(uri) {
20		tree_sitter_typescript::LANGUAGE_TSX.into()
21	} else {
22		tree_sitter_typescript::LANGUAGE_TYPESCRIPT.into()
23	};
24	parser.set_language(&language).unwrap_or_else(|err| {
25		panic!("failed to load tree-sitter TypeScript grammar: {err}");
26	});
27	parser.parse(source, None).unwrap_or_else(|| {
28		panic!("tree-sitter parse returned None on a non-cancelled call");
29	})
30}
31
32fn uri_uses_jsx(uri: &str) -> bool {
33	uri.ends_with(".tsx") || uri.ends_with(".jsx")
34}
35
36#[derive(Clone, Debug, Default)]
37pub struct Presets {
38	pub di_register_callees: Vec<String>,
39	pub path_aliases: Vec<PathAlias>,
40}
41
42#[derive(Clone, Debug)]
43pub struct PathAlias {
44	pub pattern: String,
45	pub substitution: String,
46}
47
48pub fn extract(
49	uri: &str,
50	source: &str,
51	anchor: &Moniker,
52	deep: bool,
53	presets: &Presets,
54) -> CodeGraph {
55	sdk_pipeline::extract(uri, source, anchor, deep, presets)
56}
57
58pub struct Lang;
59
60const DEF_KINDS: &[&str] = &[
61	"class",
62	"interface",
63	"type",
64	"function",
65	"method",
66	"const",
67	"enum",
68	"constructor",
69	"field",
70	"enum_constant",
71	"namespace",
72];
73
74const DEF_KIND_SPECS: &[KindSpec] = &[
75	KindSpec::new("namespace", Shape::Namespace, 10, "namespace"),
76	KindSpec::new("class", Shape::Type, 20, "class"),
77	KindSpec::new("interface", Shape::Type, 21, "interface"),
78	KindSpec::new("enum", Shape::Type, 22, "enum"),
79	KindSpec::new("type", Shape::Type, 23, "type"),
80	KindSpec::new("constructor", Shape::Callable, 40, "constructor"),
81	KindSpec::new("method", Shape::Callable, 41, "method"),
82	KindSpec::new("function", Shape::Callable, 42, "function"),
83	KindSpec::new("field", Shape::Value, 60, "field"),
84	KindSpec::new("enum_constant", Shape::Value, 61, "enum_constant"),
85	KindSpec::new("const", Shape::Value, 62, "const"),
86];
87
88impl crate::lang::LangExtractor for Lang {
89	type Presets = Presets;
90	const LANG_TAG: &'static str = "ts";
91	const ALLOWED_KINDS: &'static [&'static str] = DEF_KINDS;
92	const KIND_SPECS: &'static [KindSpec] = DEF_KIND_SPECS;
93	const ALLOWED_VISIBILITIES: &'static [&'static str] =
94		&["public", "private", "protected", "module"];
95
96	fn extract(
97		uri: &str,
98		source: &str,
99		anchor: &Moniker,
100		deep: bool,
101		presets: &Self::Presets,
102	) -> CodeGraph {
103		extract(uri, source, anchor, deep, presets)
104	}
105}
106
107#[cfg(test)]
108mod tests {
109	use super::*;
110	use crate::core::moniker::MonikerBuilder;
111	use crate::lang::assert_conformance;
112
113	fn extract(uri: &str, source: &str, anchor: &Moniker, deep: bool) -> CodeGraph {
114		let g = super::extract(uri, source, anchor, deep, &Presets::default());
115		assert_conformance::<super::Lang>(&g, anchor);
116		g
117	}
118
119	fn make_anchor() -> Moniker {
120		MonikerBuilder::new()
121			.project(b"my-app")
122			.segment(b"path", b"main")
123			.build()
124	}
125
126	#[test]
127	fn parse_empty_source_returns_program() {
128		let tree = parse("");
129		assert_eq!(tree.root_node().kind(), "program");
130		assert_eq!(tree.root_node().child_count(), 0);
131	}
132
133	#[test]
134	fn parse_simple_class_has_class_declaration() {
135		let tree = parse("class Foo {}");
136		assert_eq!(
137			tree.root_node().child(0).unwrap().kind(),
138			"class_declaration"
139		);
140	}
141
142	#[test]
143	fn parse_invalid_syntax_marks_errors() {
144		assert!(parse("class { ").root_node().has_error());
145	}
146
147	#[test]
148	fn extract_strips_each_known_extension() {
149		let anchor = make_anchor();
150		for uri in [
151			"foo.ts", "foo.tsx", "foo.js", "foo.jsx", "foo.mjs", "foo.cjs",
152		] {
153			let g = extract(uri, "", &anchor, false);
154			let last = g.root().as_view().segments().last().unwrap();
155			assert_eq!(last.name, b"foo", "extension not stripped on {uri}");
156		}
157	}
158
159	#[test]
160	fn extract_dot_only_specifier_resolves_relative_not_external() {
161		let g = extract(
162			"src/__tests__/foo.test.ts",
163			"import { z } from \"..\";",
164			&make_anchor(),
165			false,
166		);
167		let r = g.refs().next().unwrap();
168		let target = MonikerBuilder::new()
169			.project(b"my-app")
170			.segment(b"path", b"main")
171			.segment(b"lang", b"ts")
172			.segment(b"dir", b"src")
173			.segment(b"path", b"z")
174			.build();
175		assert_eq!(r.target, target);
176	}
177
178	#[test]
179	fn extract_dotdot_import_walks_up_then_down() {
180		let g = extract(
181			"src/lib/foo.ts",
182			"import { X } from '../other';",
183			&make_anchor(),
184			false,
185		);
186		let r = g.refs().next().unwrap();
187		let target = MonikerBuilder::new()
188			.project(b"my-app")
189			.segment(b"path", b"main")
190			.segment(b"lang", b"ts")
191			.segment(b"dir", b"src")
192			.segment(b"module", b"other")
193			.segment(b"path", b"X")
194			.build();
195		assert_eq!(r.target, target);
196	}
197
198	#[test]
199	fn extract_call_to_nested_function_is_resolved() {
200		let src = r#"
201function outer() {
202    function inner() {}
203    inner();
204}
205"#;
206		let g = extract("util.ts", src, &make_anchor(), false);
207		let r = g
208			.refs()
209			.find(|r| {
210				r.kind == b"calls"
211					&& r.target
212						.as_view()
213						.segments()
214						.last()
215						.unwrap()
216						.name
217						.starts_with(b"inner")
218			})
219			.expect("calls ref for inner");
220		assert_eq!(
221			r.confidence,
222			b"resolved",
223			"call to nested fn must be resolved; got {:?}",
224			std::str::from_utf8(&r.confidence)
225		);
226		let segs: Vec<_> = r.target.as_view().segments().collect();
227		assert!(
228			segs.iter()
229				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
230			"target must be scoped under outer; got {:?}",
231			segs.iter()
232				.map(|s| (
233					std::str::from_utf8(s.kind).unwrap_or("?"),
234					std::str::from_utf8(s.name).unwrap_or("?")
235				))
236				.collect::<Vec<_>>()
237		);
238	}
239
240	#[test]
241	fn extract_call_hoists_nested_fn_used_before_decl() {
242		let src = r#"
243function outer() {
244    inner();
245    function inner() {}
246}
247"#;
248		let g = extract("util.ts", src, &make_anchor(), false);
249		let r = g
250			.refs()
251			.find(|r| {
252				r.kind == b"calls"
253					&& r.target
254						.as_view()
255						.segments()
256						.last()
257						.unwrap()
258						.name
259						.starts_with(b"inner")
260			})
261			.expect("calls ref for inner");
262		assert_eq!(
263			r.confidence,
264			b"resolved",
265			"hoisted nested fn call must be resolved; got {:?}",
266			std::str::from_utf8(&r.confidence)
267		);
268	}
269
270	#[test]
271	fn extract_reads_param_marks_confidence_local() {
272		let g = extract(
273			"util.ts",
274			"function f(x) { return x; }",
275			&make_anchor(),
276			true,
277		);
278		let r = g.refs().find(|r| r.kind == b"reads").expect("reads ref");
279		assert_eq!(r.confidence, b"local".to_vec(), "ref to a param is local");
280	}
281
282	#[test]
283	fn extract_calls_local_function_marks_confidence_local() {
284		let g = extract(
285			"util.ts",
286			"function f() { const helper = () => 1; helper(); }",
287			&make_anchor(),
288			true,
289		);
290		let r = g.refs().find(|r| r.kind == b"calls").expect("calls ref");
291		assert_eq!(
292			r.confidence,
293			b"local".to_vec(),
294			"call into a locally-bound name is local"
295		);
296	}
297
298	#[test]
299	fn extract_local_def_has_no_visibility() {
300		let g = extract(
301			"util.ts",
302			"function f() { let x = 1; }",
303			&make_anchor(),
304			true,
305		);
306		let local = g.defs().find(|d| d.kind == b"local").expect("local def");
307		assert!(
308			local.visibility.is_empty(),
309			"locals must not carry a synthetic visibility, got {:?}",
310			String::from_utf8_lossy(&local.visibility)
311		);
312	}
313
314	#[test]
315	fn extract_param_def_has_no_visibility() {
316		let g = extract("util.ts", "function f(x) {}", &make_anchor(), true);
317		let p = g.defs().find(|d| d.kind == b"param").expect("param def");
318		assert!(p.visibility.is_empty());
319	}
320
321	#[test]
322	fn extract_di_register_fires_only_when_callee_in_preset() {
323		let presets = Presets {
324			di_register_callees: vec!["register".into(), "bind".into()],
325			..Presets::default()
326		};
327		let g = super::extract(
328			"util.ts",
329			"register(UserService);",
330			&make_anchor(),
331			false,
332			&presets,
333		);
334		assert!(g.refs().any(|r| r.kind == b"di_register"));
335	}
336
337	#[test]
338	fn extract_di_register_silent_without_preset() {
339		let g = extract("util.ts", "register(UserService);", &make_anchor(), false);
340		assert!(
341			g.refs().all(|r| r.kind != b"di_register"),
342			"di_register must stay silent without a preset",
343		);
344	}
345
346	#[test]
347	fn extract_di_register_skips_non_matching_callee() {
348		let presets = Presets {
349			di_register_callees: vec!["register".into()],
350			..Presets::default()
351		};
352		let g = super::extract("util.ts", "expect(value);", &make_anchor(), false, &presets);
353		assert!(g.refs().all(|r| r.kind != b"di_register"));
354	}
355
356	#[test]
357	fn extract_di_register_register_with_name_and_factory() {
358		let presets = Presets {
359			di_register_callees: vec!["register".into()],
360			..Presets::default()
361		};
362		let g = super::extract(
363			"util.ts",
364			"register('repoStore', makeRepoStore);",
365			&make_anchor(),
366			false,
367			&presets,
368		);
369		assert!(
370			g.refs().any(|r| r.kind == b"di_register"),
371			"register('name', factory) must emit di_register on the factory identifier",
372		);
373	}
374
375	#[test]
376	fn extract_di_register_member_callee_register() {
377		let presets = Presets {
378			di_register_callees: vec!["register".into()],
379			..Presets::default()
380		};
381		let g = super::extract(
382			"util.ts",
383			"container.register('repoStore', makeRepoStore);",
384			&make_anchor(),
385			false,
386			&presets,
387		);
388		assert!(
389			g.refs().any(|r| r.kind == b"di_register"),
390			"container.register(...) must emit di_register when 'register' is in the preset",
391		);
392	}
393
394	#[test]
395	fn extract_di_register_recurses_into_factory_call_argument() {
396		let presets = Presets {
397			di_register_callees: vec!["register".into()],
398			..Presets::default()
399		};
400		let g = super::extract(
401			"util.ts",
402			"register('repoStore', asFunction(makeRepoStore));",
403			&make_anchor(),
404			false,
405			&presets,
406		);
407		assert!(
408			g.refs().any(|r| r.kind == b"di_register"),
409			"register('name', asFunction(make)) must recurse to find 'make'",
410		);
411	}
412
413	#[test]
414	fn extract_di_register_recurses_through_chained_call_postfix() {
415		let presets = Presets {
416			di_register_callees: vec!["asFunction".into()],
417			..Presets::default()
418		};
419		let g = super::extract(
420			"util.ts",
421			"asFunction(makeRepoStore).singleton();",
422			&make_anchor(),
423			false,
424			&presets,
425		);
426		assert!(
427			g.refs().any(|r| r.kind == b"di_register"),
428			"asFunction(make).singleton() chain must still register the inner 'make'",
429		);
430	}
431
432	#[test]
433	fn extract_di_register_full_awilix_pattern() {
434		let presets = Presets {
435			di_register_callees: vec!["register".into()],
436			..Presets::default()
437		};
438		let g = super::extract(
439			"util.ts",
440			"container.register('readResource', asFunction(makeReadResource).singleton());",
441			&make_anchor(),
442			false,
443			&presets,
444		);
445		assert!(
446			g.refs().any(|r| r.kind == b"di_register"),
447			"container.register('name', asFunction(make).singleton()) must emit di_register",
448		);
449	}
450	#[test]
451	fn extract_shallow_skips_param_and_local() {
452		let g = extract(
453			"util.ts",
454			"function f(a: number) { let x = 1; }",
455			&make_anchor(),
456			false,
457		);
458		assert!(
459			g.defs().all(|d| d.kind != b"param" && d.kind != b"local"),
460			"shallow extraction must not produce param/local defs"
461		);
462	}
463
464	#[test]
465	fn extract_deep_emits_params_and_locals() {
466		let g = extract(
467			"util.ts",
468			"function f(a: number, b: number) { let sum = a + b; }",
469			&make_anchor(),
470			true,
471		);
472		let pa = MonikerBuilder::new()
473			.project(b"my-app")
474			.segment(b"path", b"main")
475			.segment(b"lang", b"ts")
476			.segment(b"module", b"util")
477			.segment(b"function", b"f(a:number,b:number)")
478			.segment(b"param", b"a")
479			.build();
480		let pb = MonikerBuilder::new()
481			.project(b"my-app")
482			.segment(b"path", b"main")
483			.segment(b"lang", b"ts")
484			.segment(b"module", b"util")
485			.segment(b"function", b"f(a:number,b:number)")
486			.segment(b"param", b"b")
487			.build();
488		let sum = 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"local", b"sum")
495			.build();
496		assert!(
497			g.contains(&pa),
498			"missing param a; defs: {:?}",
499			g.def_monikers()
500		);
501		assert!(g.contains(&pb));
502		assert!(g.contains(&sum));
503	}
504
505	#[test]
506	fn extract_deep_anonymous_callback_uses_position_name() {
507		let g = extract(
508			"util.ts",
509			"function f() { [1].map(x => x); }",
510			&make_anchor(),
511			true,
512		);
513		let monikers = g.def_monikers();
514		let cb = monikers
515			.iter()
516			.find(|m| {
517				let last = m.as_view().segments().last().unwrap();
518				last.kind == b"function" && last.name.starts_with(b"__cb_")
519			})
520			.expect("anonymous callback def with __cb_ prefix")
521			.clone();
522		let view = cb.as_view();
523		let last = view.segments().last().unwrap();
524		assert_eq!(last.kind, b"function");
525		assert!(g.defs().any(|d| {
526			let dv = d.moniker.as_view();
527			dv.segment_count() == view.segment_count() + 1
528				&& dv.segments().last().unwrap().kind == b"param"
529		}));
530	}
531
532	#[test]
533	fn extract_alias_import_routes_to_project_rooted_module() {
534		let presets = Presets {
535			path_aliases: vec![PathAlias {
536				pattern: "@/*".into(),
537				substitution: "./src/*".into(),
538			}],
539			..Presets::default()
540		};
541		let g = super::extract(
542			"src/router.tsx",
543			"import { AppShell } from '@/components/layout/app-shell';",
544			&make_anchor(),
545			false,
546			&presets,
547		);
548		let r = g.refs().next().expect("one ref");
549		let target = MonikerBuilder::new()
550			.project(b"my-app")
551			.segment(b"path", b"main")
552			.segment(b"lang", b"ts")
553			.segment(b"dir", b"src")
554			.segment(b"dir", b"components")
555			.segment(b"dir", b"layout")
556			.segment(b"module", b"app-shell")
557			.segment(b"path", b"AppShell")
558			.build();
559		assert_eq!(
560			r.target, target,
561			"alias-resolved import must point at the project-rooted module, not external_pkg",
562		);
563	}
564
565	#[test]
566	fn extract_alias_import_keeps_external_when_no_alias_matches() {
567		let presets = Presets {
568			path_aliases: vec![PathAlias {
569				pattern: "@/*".into(),
570				substitution: "./src/*".into(),
571			}],
572			..Presets::default()
573		};
574		let g = super::extract(
575			"util.ts",
576			"import { join } from '@scope/pkg/sub';",
577			&make_anchor(),
578			false,
579			&presets,
580		);
581		let r = g.refs().next().unwrap();
582		let head = r.target.as_view().segments().next().unwrap();
583		assert_eq!(head.kind, b"external_pkg");
584	}
585
586	#[test]
587	fn extract_unknown_member_call_does_not_claim_sdk_provenance() {
588		let g = extract(
589			"app.ts",
590			"function read(store: Store) { return store.getState(); }",
591			&make_anchor(),
592			false,
593		);
594		let call = g
595			.refs()
596			.find(|reference| {
597				reference.kind == b"method_call"
598					&& reference
599						.target
600						.as_view()
601						.segments()
602						.last()
603						.is_some_and(|segment| segment.name == b"getState")
604			})
605			.expect("getState method call");
606		let first = call.target.as_view().segments().next().unwrap();
607		assert_ne!(first.kind, b"sdk");
608		assert_eq!(call.confidence.as_ref(), b"name_match");
609	}
610
611	#[test]
612	fn extract_global_member_call_preserves_runtime_owner() {
613		let g = extract(
614			"app.ts",
615			"function read() { return Object.fromEntries([]); }",
616			&make_anchor(),
617			false,
618		);
619		let call = g
620			.refs()
621			.find(|reference| reference.kind == b"method_call")
622			.expect("Object.fromEntries method call");
623		let segments = call.target.as_view().segments().collect::<Vec<_>>();
624		assert_eq!(
625			segments
626				.iter()
627				.map(|segment| (segment.kind, segment.name))
628				.collect::<Vec<_>>(),
629			vec![
630				(b"sdk".as_slice(), b"ts".as_slice()),
631				(b"path".as_slice(), b"runtime".as_slice()),
632				(b"path".as_slice(), b"Object".as_slice()),
633				(b"method".as_slice(), b"fromEntries".as_slice()),
634			]
635		);
636	}
637
638	#[test]
639	fn extract_local_binding_shadows_runtime_global_for_member_calls() {
640		let g = extract(
641			"app.ts",
642			"interface Logger { flush(): void } function run(console: Logger) { console.flush(); }",
643			&make_anchor(),
644			true,
645		);
646		let call = g
647			.refs()
648			.find(|reference| {
649				reference.kind == b"method_call" && reference.call_name.as_ref() == b"flush"
650			})
651			.expect("console.flush method call");
652		assert_ne!(
653			call.target.as_view().segments().next().unwrap().kind,
654			b"sdk",
655			"a lexical binding named like a runtime global must win",
656		);
657	}
658
659	#[test]
660	fn extract_module_binding_shadows_runtime_global_for_member_calls() {
661		let g = extract(
662			"app.ts",
663			"const console = { flush() {} }; export function run() { console.flush(); }",
664			&make_anchor(),
665			true,
666		);
667		let call = g
668			.refs()
669			.find(|reference| {
670				reference.kind == b"method_call" && reference.call_name.as_ref() == b"flush"
671			})
672			.expect("console.flush method call");
673		assert_ne!(
674			call.target.as_view().segments().next().unwrap().kind,
675			b"sdk",
676			"a module binding named like a runtime global must win",
677		);
678		let segments = call.target.as_view().segments().collect::<Vec<_>>();
679		assert!(
680			segments.windows(2).any(|pair| {
681				pair[0].kind == b"const"
682					&& pair[0].name == b"console"
683					&& pair[1].kind == b"method"
684					&& pair[1].name == b"flush"
685			}),
686			"unexpected target {segments:?}",
687		);
688	}
689
690	#[test]
691	fn extract_jsx_expression_identifier_still_emits_read() {
692		let g = extract(
693			"app.tsx",
694			"function App(label: string) { return <div>{label}</div>; }",
695			&make_anchor(),
696			true,
697		);
698		assert!(
699			g.refs().any(|r| r.kind == b"reads"
700				&& r.target.as_view().segments().last().unwrap().name == b"label"),
701			"identifier inside jsx_expression must still surface as a read",
702		);
703	}
704
705	#[test]
706	fn extract_closure_read_targets_outer_param_def() {
707		let src = "function outer({ x }: { x: string }) { return function inner() { return x; }; }";
708		let g = extract("util.ts", src, &make_anchor(), true);
709		let read = g
710			.refs()
711			.find(|r| {
712				r.kind == b"reads" && r.target.as_view().segments().last().unwrap().name == b"x"
713			})
714			.expect("reads ref for x");
715		let segs: Vec<_> = read.target.as_view().segments().collect();
716		assert!(
717			segs.iter().any(|s| s.kind == b"param" && s.name == b"x"),
718			"target must terminate with param:x of the defining frame, got: {segs:?}"
719		);
720		assert!(
721			!segs
722				.iter()
723				.any(|s| s.kind == b"function" && s.name == b"inner()"),
724			"target must NOT carry the inner frame segment, got: {segs:?}"
725		);
726	}
727
728	#[test]
729	fn extract_closure_uses_type_targets_outer_type_alias_def() {
730		let src = "function outer() { type Local = string; function inner(x: Local): Local { return x; } return inner; }";
731		let g = extract("util.ts", src, &make_anchor(), true);
732		let r = g
733			.refs()
734			.find(|r| {
735				r.kind == b"uses_type"
736					&& r.target.as_view().segments().last().unwrap().name == b"Local"
737			})
738			.expect("uses_type ref for Local");
739		let segs: Vec<_> = r.target.as_view().segments().collect();
740		assert!(
741			segs.iter().any(|s| s.kind == b"type" && s.name == b"Local"),
742			"target must terminate with type:Local of the defining frame, got: {segs:?}"
743		);
744		assert!(
745			segs.iter()
746				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
747			"target must be parented under outer (the defining frame), got: {segs:?}"
748		);
749	}
750
751	#[test]
752	fn extract_closure_uses_type_targets_outer_interface_def() {
753		let src = "function outer() { interface Local { v: string; } function inner(x: Local): Local { return x; } return inner; }";
754		let g = extract("util.ts", src, &make_anchor(), true);
755		let r = g
756			.refs()
757			.find(|r| {
758				r.kind == b"uses_type"
759					&& r.target.as_view().segments().last().unwrap().name == b"Local"
760			})
761			.expect("uses_type ref for Local");
762		let segs: Vec<_> = r.target.as_view().segments().collect();
763		assert!(
764			segs.iter()
765				.any(|s| s.kind == b"interface" && s.name == b"Local"),
766			"target must terminate with interface:Local of the defining frame, got: {segs:?}"
767		);
768		assert!(
769			segs.iter()
770				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
771			"target must be parented under outer, got: {segs:?}"
772		);
773	}
774
775	#[test]
776	fn extract_closure_instantiates_targets_outer_class_def() {
777		let src = "function outer() { class Local { ok = true; } function inner() { return new Local(); } return inner; }";
778		let g = extract("util.ts", src, &make_anchor(), true);
779		let r = g
780			.refs()
781			.find(|r| {
782				r.kind == b"instantiates"
783					&& r.target.as_view().segments().last().unwrap().name == b"Local"
784			})
785			.expect("instantiates ref for Local");
786		let segs: Vec<_> = r.target.as_view().segments().collect();
787		assert!(
788			segs.iter()
789				.any(|s| s.kind == b"class" && s.name == b"Local"),
790			"target must terminate with class:Local of the defining frame, got: {segs:?}"
791		);
792		assert!(
793			segs.iter()
794				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
795			"target must be parented under outer, got: {segs:?}"
796		);
797	}
798
799	#[test]
800	fn extract_closure_uses_type_targets_outer_enum_def() {
801		let src = "function outer() { enum Mode { A, B } function inner(m: Mode): Mode { return m; } return inner; }";
802		let g = extract("util.ts", src, &make_anchor(), true);
803		let r = g
804			.refs()
805			.find(|r| {
806				r.kind == b"uses_type"
807					&& r.target.as_view().segments().last().unwrap().name == b"Mode"
808			})
809			.expect("uses_type ref for Mode");
810		let segs: Vec<_> = r.target.as_view().segments().collect();
811		assert!(
812			segs.iter().any(|s| s.kind == b"enum" && s.name == b"Mode"),
813			"target must terminate with enum:Mode of the defining frame, got: {segs:?}"
814		);
815		assert!(
816			segs.iter()
817				.any(|s| s.kind == b"function" && s.name.starts_with(b"outer")),
818			"target must be parented under outer, got: {segs:?}"
819		);
820	}
821
822	#[test]
823	fn extract_closure_call_targets_outer_local_def() {
824		let src = "function outer() { const helper = () => 1; return function inner() { return helper(); }; }";
825		let g = extract("util.ts", src, &make_anchor(), true);
826		let call = g
827			.refs()
828			.find(|r| {
829				r.kind == b"calls"
830					&& r.target.as_view().segments().last().unwrap().name == b"helper"
831			})
832			.expect("calls ref for helper");
833		let segs: Vec<_> = call.target.as_view().segments().collect();
834		assert!(
835			segs.iter()
836				.any(|s| s.kind == b"local" && s.name == b"helper"),
837			"target must terminate with local:helper of the defining frame, got: {segs:?}"
838		);
839		assert!(
840			!segs
841				.iter()
842				.any(|s| s.kind == b"function" && s.name == b"inner()"),
843			"target must NOT carry the inner frame segment, got: {segs:?}"
844		);
845	}
846}