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}