Skip to main content

devscout_rs/
extract.rs

1// Purposes + def/ref candidates + enum-member defs, onto native tree-sitter
2// behind the grammar seam in parse.rs. This is the AST-purpose and def/ref
3// extractor; see offsets.rs for the span translation it depends on.
4//
5// Scope (purpose composition + graph-fragment extraction, run per file):
6//   - purpose: a compact one-line signature of a file's namespace-level
7//     types (class/interface/struct/record/enum), truncated to 200 UTF-16
8//     code units.
9//   - defs: namespace-level + nested types (id = dotted namespace, "+"
10//     joined for nested-type chains) and enum members (id =
11//     "<EnumFQN>.<Member>"), each with its public method names.
12//   - usings: using directives, either {alias, target, global} (alias
13//     form) or {text, global} (plain/static/global form).
14//   - refs: uses-type / inherits / uses-member / imports candidates with
15//     line + enclosing-namespace context.
16//
17// Def/ref extraction NEVER emits a byte offset or a column -- only the
18// node's start row + 1 (a 1-based line number). The offset table exists to
19// translate UTF-8-byte offsets/columns to UTF-16 code units; rows are
20// unaffected by that translation, so nothing here needs OffsetTable -- an
21// absence, not a gap.
22
23use std::cell::OnceCell;
24use std::collections::{HashMap, HashSet};
25use std::fs;
26use std::path::Path;
27use std::process;
28use std::rc::Rc;
29
30use serde::{Deserialize, Serialize};
31use tree_sitter::{Node, Parser};
32
33const MAX_PURPOSE: usize = 200;
34
35// (grammar node kind, TYPE_KINDS label) pairs, iterated as an ordered slice
36// to preserve declaration order.
37const TYPE_KINDS: &[(&str, &str)] = &[
38    ("class_declaration", "class"),
39    ("interface_declaration", "interface"),
40    ("struct_declaration", "struct"),
41    ("record_declaration", "record"),
42    ("enum_declaration", "enum"),
43];
44
45const NAMESPACE_NODES: &[&str] = &["namespace_declaration", "file_scoped_namespace_declaration"];
46
47fn type_kind_label(kind: &str) -> Option<&'static str> {
48    TYPE_KINDS
49        .iter()
50        .find(|(k, _)| *k == kind)
51        .map(|(_, label)| *label)
52}
53
54fn new_parser() -> Parser {
55    let mut parser = Parser::new();
56    parser
57        .set_language(&tree_sitter_c_sharp::LANGUAGE.into())
58        .expect("failed to load C# grammar");
59    parser
60}
61
62fn named_children<'a>(node: Node<'a>) -> Vec<Node<'a>> {
63    let mut cursor = node.walk();
64    node.named_children(&mut cursor).collect()
65}
66
67fn text(node: Node, src: &[u8]) -> String {
68    crate::parse::node_text(node, src)
69}
70
71fn declared_name(node: Node, src: &[u8]) -> String {
72    node.child_by_field_name("name")
73        .map(|n| text(n, src))
74        .unwrap_or_default()
75}
76
77// ---------------------------------------------------------------------------
78// Purpose/signature composition.
79// ---------------------------------------------------------------------------
80
81fn namespace_level_types<'a>(root: Node<'a>) -> Vec<Node<'a>> {
82    let mut types = Vec::new();
83    collect_types(root, &mut types);
84    types
85}
86
87fn collect_types<'a>(node: Node<'a>, types: &mut Vec<Node<'a>>) {
88    for child in named_children(node) {
89        if NAMESPACE_NODES.contains(&child.kind()) {
90            let body = child.child_by_field_name("body").unwrap_or(child);
91            collect_types(body, types);
92            continue;
93        }
94        if type_kind_label(child.kind()).is_some() {
95            types.push(child);
96        }
97    }
98}
99
100fn base_list_text(node: Node, src: &[u8]) -> String {
101    let Some(bases) = named_children(node)
102        .into_iter()
103        .find(|c| c.kind() == "base_list")
104    else {
105        return String::new();
106    };
107    let raw = text(bases, src);
108    let after_colon = match raw.strip_prefix(':') {
109        Some(rest) => rest.trim_start(),
110        None => raw.as_str(),
111    };
112    collapse_whitespace(after_colon).trim().to_string()
113}
114
115fn collapse_whitespace(s: &str) -> String {
116    let mut out = String::with_capacity(s.len());
117    let mut in_ws = false;
118    for ch in s.chars() {
119        if ch.is_whitespace() {
120            if !in_ws {
121                out.push(' ');
122                in_ws = true;
123            }
124        } else {
125            out.push(ch);
126            in_ws = false;
127        }
128    }
129    out
130}
131
132fn is_public(node: Node, src: &[u8]) -> bool {
133    named_children(node)
134        .into_iter()
135        .any(|c| c.kind() == "modifier" && text(c, src) == "public")
136}
137
138fn strip_async_suffix(name: &str) -> String {
139    name.strip_suffix("Async").unwrap_or(name).to_string()
140}
141
142// Purpose-signature method names: trailing "Async" stripped, deduped by the
143// caller across the whole file. NOT the same helper as raw_method_names --
144// a graph def needs the real, unabridged name (see that function's own
145// comment).
146fn public_method_names(node: Node, src: &[u8], kind: &str) -> Vec<String> {
147    let Some(body) = node.child_by_field_name("body") else {
148        return Vec::new();
149    };
150    named_children(body)
151        .into_iter()
152        .filter(|c| c.kind() == "method_declaration" && (kind == "interface" || is_public(*c, src)))
153        .map(|c| strip_async_suffix(&declared_name(c, src)))
154        .filter(|n| !n.is_empty())
155        .collect()
156}
157
158// One namespace-level type's raw ingredients for a purpose segment --
159// (line, kind, name, bases, unfiled public method names). Line comes along
160// so `extract()`'s type-declaration-recovery path (below) can merge
161// recovered types' parts into this list at the right sorted position
162// before formatting; the ordinary (non-recovery) path never inspects it.
163type SegmentParts = (usize, &'static str, String, String, Vec<String>);
164
165fn type_segment_parts(node: Node, src: &[u8]) -> Option<SegmentParts> {
166    let kind = type_kind_label(node.kind())?;
167    let name = declared_name(node, src);
168    if name.is_empty() {
169        return None;
170    }
171    let bases = base_list_text(node, src);
172    let methods = public_method_names(node, src, kind);
173    Some((node.start_position().row + 1, kind, name, bases, methods))
174}
175
176// Formats an already line-ordered list of segment parts into one purpose
177// string: "kind name[ : bases][; m1, m2]" joined with " | ", methods
178// deduped globally across the whole list in order (a method name credited
179// to an earlier type in the list is never repeated on a later one -- port
180// of astPurposes' own cross-type dedup), then truncated to MAX_PURPOSE
181// UTF-16 code units.
182fn format_segments(parts: Vec<SegmentParts>) -> Option<String> {
183    let mut seen_methods: HashSet<String> = HashSet::new();
184    let mut segments: Vec<String> = Vec::new();
185    for (_line, kind, name, bases, raw_methods) in parts {
186        let mut methods = Vec::new();
187        for method in raw_methods {
188            if seen_methods.contains(&method) {
189                continue;
190            }
191            seen_methods.insert(method.clone());
192            methods.push(method);
193        }
194        let header = if bases.is_empty() {
195            format!("{kind} {name}")
196        } else {
197            format!("{kind} {name} : {bases}")
198        };
199        let segment = if methods.is_empty() {
200            header
201        } else {
202            format!("{header}; {}", methods.join(", "))
203        };
204        segments.push(segment);
205    }
206    if segments.is_empty() {
207        None
208    } else {
209        Some(truncate(&segments.join(" | ")))
210    }
211}
212
213// NB: no `compose_signature(types, src)` wrapper -- extract()'s only
214// caller needs to interleave a second (recovered) parts list before
215// formatting, so it calls type_segment_parts()/format_segments() directly.
216
217// The 200-unit limit counts UTF-16 code units, not bytes or chars -- matched
218// here via `encode_utf16` rather than `str::len`/char indexing, so a purpose
219// string with non-ASCII identifiers truncates at a code-unit boundary.
220fn truncate(s: &str) -> String {
221    let units: Vec<u16> = s.encode_utf16().collect();
222    if units.len() > MAX_PURPOSE {
223        let mut truncated = String::from_utf16_lossy(&units[..MAX_PURPOSE - 3]);
224        truncated.push_str("...");
225        truncated
226    } else {
227        s.to_string()
228    }
229}
230
231// ---------------------------------------------------------------------------
232// Graph fragment extraction and its helpers. Scope: defs =
233// namespace-level+nested types with public method names plus enum members;
234// refs = using directives, base-list types,
235// object-creation types, field/property/parameter/return types, generic
236// type arguments, and member-access qualifier.member candidates. No
237// call-graph resolution.
238// ---------------------------------------------------------------------------
239
240/// Represents `DefRecord`.
241pub struct DefRecord {
242    /// The id value.
243    pub id: String,
244    /// The name value.
245    pub name: String,
246    /// The namespace value.
247    pub namespace: String,
248    /// The kind value.
249    pub kind: String,
250    /// The line value.
251    pub line: usize,
252    /// The methods value.
253    pub methods: Vec<String>,
254    /// Declared property names, source order, deduped
255    /// (indexers excluded by construction: `indexer_declaration` is a
256    /// different grammar node). NO accessibility filter, deliberately
257    /// asymmetric with `methods`: static/const/readonly/expression-bodied all
258    /// count and only indexers are excluded, so a private member is still a
259    /// member of the type. Empty for every
260    /// enum-member def.
261    pub properties: Vec<String>,
262    /// Declared field names, source order, deduped, every
263    /// declarator of every `field_declaration` ("private int a, b;"
264    /// contributes both). `event_field_declaration` is a distinct node type
265    /// and is NOT a field for this purpose. Same no-accessibility-filter
266    /// rule as `properties`.
267    pub fields: Vec<String>,
268    /// (method name, declared return type NAME) pairs in
269    /// FIRST-declaration source order, parallel to `methods` (same
270    /// `is_recorded_method` predicate, so the two can never drift). The
271    /// first declaration of a name claims the slot outright: a later
272    /// overload with a different return type is ignored, and a first
273    /// declaration whose return type yields no fact (void, var, a
274    /// predefined type) BLOCKS the name rather than letting a later
275    /// overload stand in for it. A Vec of pairs, not a map: the serialized
276    /// key order is significant (see graph.rs's FragDef).
277    pub method_returns: Vec<(String, String)>,
278    /// The extension methods this type declares: every method
279    /// whose FIRST parameter carries the `this` modifier, in source order,
280    /// deduped by (name, thisType, arityMin, arityMax). Appended LAST, after
281    /// `method_returns`, and omitted at serialization when empty. Unlike
282    /// `methods` there is NO accessibility filter (`internal static class
283    /// FooExtensions` is the shape this feature exists for) and no `static`
284    /// check on either the method or its class -- C# already disallows a
285    /// `this` parameter anywhere else, so the parameter modifier IS the
286    /// discriminator.
287    pub extension_methods: Vec<ExtensionMethod>,
288    /// The DIRECT base-type identifiers this
289    /// declaration lists, in source order, deduped. Same base_list traversal
290    /// `record_base_list` walks for its `inherits` refs, reduced to a base
291    /// IDENTIFIER because the resolver re-RESOLVES these names through the
292    /// ordinary ladder rather than matching them; the resulting closure is
293    /// what lets the extension tier see an inherited instance member and
294    /// decline. Appended after `extension_methods` and omitted when empty.
295    pub bases: Vec<String>,
296    /// The declaring type's OWN type-parameter names (`class
297    /// MongoRepository<T>` records `["T"]`), empty for every non-generic
298    /// declaration. This is the ctor-DI resolver's "is this def itself an
299    /// open-generic implementation" signal. Appended after `bases`, omitted
300    /// when empty.
301    pub type_params: Vec<String>,
302    /// Per base name that carried a type-argument list, that
303    /// list's generic-arg descriptors relative to `type_params` (a `"*"`
304    /// wildcard marks a position that is a pass-through of the declaring
305    /// type's own parameter). A `Vec` of pairs, not a map, for the same
306    /// reason `method_returns` is: the serialized key order is significant.
307    /// Appended after `type_params`, omitted when empty; a base
308    /// with no type-argument list at all contributes no entry.
309    pub base_generic_args: Vec<(String, Vec<String>)>,
310    /// The methods this type declares that a test
311    /// framework would DISCOVER as tests, in source order, deduped. Appended
312    /// LAST, after `base_generic_args`, and omitted at serialization when
313    /// empty. The attribute IS the fact, which is why this is the one member
314    /// fact with no accessibility filter at all and why no file, folder or
315    /// type-name convention is read anywhere.
316    pub test_methods: Vec<String>,
317    /// (property name, declared type fact) pairs for exactly the
318    /// properties `properties` records, in the same source order and under the
319    /// same dedup. A property whose declared type yields no fact (a predefined
320    /// type) has no entry, so the two lists are parallel but not equal in
321    /// length. A `Vec` of pairs, not a map, for the same reason
322    /// `method_returns` is one: the serialized key order is significant.
323    /// Appended LAST, after `test_methods`.
324    pub property_types: Vec<(String, Fact)>,
325    /// 1-based last line of the complete declaration node.
326    pub end_line: usize,
327}
328
329/// One `extensionMethods` entry. Field order (`name`, `thisType`, `arityMin`,
330/// `arityMax`, `thisArgs`) is significant: it fixes the serialized field
331/// order.
332#[derive(Debug, Clone, PartialEq)]
333pub struct ExtensionMethod {
334    /// The name value.
335    pub name: String,
336    /// The this type value.
337    pub this_type: String,
338    /// Non-this parameters a caller cannot
339    /// leave out: no default value AND no `params` modifier.
340    pub arity_min: usize,
341    /// Total non-this parameter count, or -1 when the trailing parameter is a
342    /// `params` array (unbounded above). Signed precisely so -1 can be the
343    /// sentinel written as a JSON number.
344    pub arity_max: i64,
345    /// The this-parameter type's TOP-LEVEL
346    /// type-argument descriptors, present only when that type is generic.
347    /// A position naming one of the method's or the enclosing class's own type
348    /// parameters is recorded as "*" (wildcard).
349    pub this_args: Option<Vec<String>>,
350}
351
352/// Represents `RefRecord`.
353pub struct RefRecord {
354    /// The kind value.
355    pub kind: String,
356    /// The name value.
357    pub name: String,
358    /// The qualified value.
359    pub qualified: Option<String>,
360    /// The member value.
361    pub member: Option<String>,
362    /// The line value.
363    pub line: usize,
364    /// `None` only for 'imports' refs -- a using directive is not
365    /// namespace-scoped, so `ns` is deliberately `null`. Every other ref
366    /// carries `Some(ns)`, where `ns`
367    /// may itself be the empty string at file scope.
368    pub namespace: Option<String>,
369    /// The type arg count value.
370    pub type_arg_count: Option<usize>,
371    /// `true` when a uses-member qualifier carried a
372    /// type-argument list anywhere (`Cache<T>.x`, `Ns.Cache<T>.x`): syntax
373    /// only a TYPE can carry, so the resolver treats it as type-certainty
374    /// for non-enum member emission. Always `false` for every other ref
375    /// kind.
376    pub generic: bool,
377    /// The declared type NAME of the receiver a uses-member
378    /// qualifier names, when the enclosing scope holds exactly one fact for
379    /// it (a declared local, a `var x = new T()` local, a parameter, a
380    /// field, or a primary-constructor parameter). Set ONLY for a BARE,
381    /// non-generic qualifier -- see the `member_access_expression` arm's
382    /// guard, which is what makes the chain-tail hazard structurally
383    /// impossible. Always `None` for every other ref kind.
384    pub receiver_type: Option<String>,
385    /// The argument count of the call this
386    /// member access is the CALLEE of (`argument_list` named-child count),
387    /// recorded only when the access is the `function` field of an
388    /// `invocation_expression`. A property read (`x.P`) carries `None`, which
389    /// is what keeps it out of the arity-matched extension tier entirely.
390    /// Appended AFTER `receiver_type`. Always `None` for every other ref kind.
391    pub arg_count: Option<usize>,
392    /// The DECLARED receiver type's top-level
393    /// type-argument descriptors, present only when that type is generic. A
394    /// position naming a type parameter of the enclosing method or class is
395    /// recorded as "*" (wildcard): nothing at the fact site knows its binding.
396    /// Travels with `receiver_type` -- the fact carries both or neither.
397    /// Appended LAST, after `arg_count`.
398    pub receiver_args: Option<Vec<String>>,
399    /// The walk's own type_stack verbatim: the enclosing type simple
400    /// names, OUTERMOST first, the same order type_id joins with "+" to build
401    /// a nested def id. Empty at namespace level, and empty is exactly what an
402    /// absent key deserializes to. Appended LAST, after `receiver_args`.
403    pub outer_types: Vec<String>,
404    /// Generic-arg descriptors for a 'ctor-param' ref's
405    /// parameter type (same descriptor shape as `receiver_args`: a `"*"`
406    /// wildcard for a pass-through of the enclosing type's own type
407    /// parameter), present only when that type is generic. `None` for every
408    /// other ref kind. Appended LAST of all, after `outer_types` -- 'ctor-param'
409    /// is a ref kind no other caller touches, so putting it after that
410    /// "last of all" field costs no other ref kind a single byte.
411    pub args: Option<Vec<String>>,
412    /// The type whose PROPERTY the qualifier's last segment is, for
413    /// a two-segment chain whose head the enclosing scope has a fact for
414    /// ("a.Settings" in `a.Settings.Reload()`). Never travels with
415    /// `receiver_type`: that one is bare-qualifier-only and this one is
416    /// dotted-qualifier-only, which is what keeps the chain-tail hazard
417    /// structurally impossible -- the resolver reaches the tail's type through
418    /// the head type's RECORDED property types, never by inheriting the head's
419    /// own. Appended after `args`.
420    pub receiver_property_owner: Option<String>,
421    /// The type whose METHOD a `var x = Q.M(...)` initializer
422    /// called, and that method's name. Always set as a pair, and never
423    /// alongside `receiver_type`: the local's type is whatever `M` returns, a
424    /// lookup only the resolver can do. Appended LAST of all.
425    pub receiver_call_owner: Option<String>,
426    /// The receiver call member value.
427    pub receiver_call_member: Option<String>,
428}
429
430/// Represents `UsingRecord`.
431pub enum UsingRecord {
432    /// The value value.
433    Alias {
434        /// The alias name.
435        alias: String,
436        /// The aliased target.
437        target: String,
438        /// Whether the directive is global.
439        global: bool,
440    },
441    /// The value value.
442    Plain {
443        /// The imported namespace text.
444        text: String,
445        /// Whether the directive is global.
446        global: bool,
447    },
448}
449
450/// Represents `Extraction`.
451pub struct Extraction {
452    /// The purpose value.
453    pub purpose: Option<String>,
454    /// The defs value.
455    pub defs: Vec<DefRecord>,
456    /// The usings value.
457    pub usings: Vec<UsingRecord>,
458    /// The refs value.
459    pub refs: Vec<RefRecord>,
460    /// Every member the file's types declare, appended LAST after
461    /// `refs` in both the fragment and the `extract-dump` shape.
462    pub names: Vec<NameRecord>,
463}
464
465/// One declared name and the line its own NAME TOKEN sits on. Deliberately
466/// not the declaration node's start row: a member's span begins
467/// at its attribute list, so an attributed member would point a reader at the
468/// `[` line instead of the line carrying the name they searched for.
469///
470/// Serialized field order (`name`, `kind`, `line`, `owner`) is significant;
471/// `owner` is omitted when empty, which is how a markup or resource
472/// key -- built by `markup.rs`, owned by no C# type -- serializes with three
473/// fields.
474#[derive(Debug, Clone, PartialEq)]
475pub struct NameRecord {
476    /// The name value.
477    pub name: String,
478    /// The kind value.
479    pub kind: String,
480    /// The line value.
481    pub line: usize,
482    /// The owner value.
483    pub owner: String,
484}
485
486// Unwraps nullable/array/alias-qualified wrappers,
487// pulls the base identifier out of a generic_name (discarding its type
488// arguments -- those are recorded separately when the walk reaches the
489// type_argument_list node), and takes qualified_name's full dotted text
490// verbatim. Anything else (predefined_type, implicit_type/`var`, ...) is
491// not a user-defined type reference.
492fn outer_type_name(node: Node, src: &[u8]) -> Option<String> {
493    match node.kind() {
494        "nullable_type" | "array_type" => match node.child_by_field_name("type") {
495            Some(inner) => outer_type_name(inner, src),
496            None => None,
497        },
498        "generic_name" => named_children(node)
499            .into_iter()
500            .find(|c| c.kind() == "identifier")
501            .map(|id| text(id, src)),
502        "qualified_name" => {
503            let tail = node.child_by_field_name("name")?;
504            let full = text(node, src);
505            let tail_text = text(tail, src);
506            let normalized_tail = outer_type_name(tail, src)?;
507            full.strip_suffix(&tail_text)
508                .map(|prefix| format!("{prefix}{normalized_tail}"))
509        }
510        "alias_qualified_name" => match node.child_by_field_name("name") {
511            Some(n) => outer_type_name(n, src),
512            None => None,
513        },
514        "identifier" => Some(text(node, src)),
515        _ => None,
516    }
517}
518
519// The base IDENTIFIER of a type node, for the local type facts. Deliberately
520// NOT outer_type_name: that one
521// returns a qualified name's FULL dotted text because the resolution ladder
522// tries an exact-FQN match first. A stage-2 fact is a NAME only -- generic
523// arguments stripped, a qualified name reduced to its last segment, and
524// predefined types (string/int/void/...) plus `var` (implicit_type) yielding
525// None, which every caller reads as "no fact" rather than as a type called
526// "string". An empty result is folded to None as well: every caller guards
527// with a presence check, for which "" and absent are the same answer.
528//
529// `keep_predefined` flips ONLY the predefined-type case, for the
530// one fact family that wants "string" as an answer rather than as a refusal:
531// an extension method's this-parameter type. `this string s` is legal and
532// common C#, and its thisType is a name a receiver has to match exactly, not a
533// def anyone resolves. Every local-fact caller passes `false` and therefore
534// keeps its exact prior output.
535fn base_type_identifier(node: Option<Node>, src: &[u8], keep_predefined: bool) -> Option<String> {
536    let node = node?;
537    let name = match node.kind() {
538        "nullable_type" | "array_type" => {
539            base_type_identifier(node.child_by_field_name("type"), src, keep_predefined)
540        }
541        "generic_name" => named_children(node)
542            .into_iter()
543            .find(|c| c.kind() == "identifier")
544            .map(|id| text(id, src)),
545        "qualified_name" | "alias_qualified_name" => {
546            base_type_identifier(node.child_by_field_name("name"), src, keep_predefined)
547        }
548        "identifier" => Some(text(node, src)),
549        "predefined_type" => {
550            if keep_predefined {
551                Some(text(node, src))
552            } else {
553                None
554            }
555        }
556        _ => None,
557    };
558    name.filter(|n| !n.is_empty())
559}
560
561// The generic_name a type node bottoms out at, following the SAME unwrapping
562// path
563// base_type_identifier takes (nullable and array wrappers, qualified/alias
564// tails), or None when the type is not generic at its top level. `Ns.Box<T>`
565// parses as a qualified_name whose `name` field IS the generic_name, which is
566// why the qualified case recurses rather than stopping.
567fn top_level_generic_name(node: Option<Node>) -> Option<Node> {
568    let node = node?;
569    match node.kind() {
570        "nullable_type" | "array_type" => top_level_generic_name(node.child_by_field_name("type")),
571        "qualified_name" | "alias_qualified_name" => {
572            top_level_generic_name(node.child_by_field_name("name"))
573        }
574        "generic_name" => Some(node),
575        _ => None,
576    }
577}
578
579fn type_argument_arity(node: Option<Node>, src: &[u8]) -> Option<usize> {
580    let generic = top_level_generic_name(node)?;
581    let list = named_children(generic)
582        .into_iter()
583        .find(|c| c.kind() == "type_argument_list")?;
584    Some(text(list, src).bytes().filter(|b| *b == b',').count() + 1)
585}
586
587// The TOP-LEVEL generic argument descriptors of a type node, or None when it
588// carries no
589// type-argument list (`Box<>`, an unbound generic, counts as none). One
590// descriptor per argument, in source order: the argument's base identifier
591// (predefined types KEPT, so `IDictionary<string, object>` records
592// ["string", "object"]), except a position naming one of `type_params` -- the
593// enclosing method's or type's own type parameters -- which records "*".
594//
595// A single argument that yields NO base identifier at all (a tuple type, a
596// pointer) drops the WHOLE list: the match rule compares positions by index,
597// so a partial list would silently shift every argument after the hole.
598fn generic_arg_descriptors(
599    node: Option<Node>,
600    src: &[u8],
601    type_params: &HashSet<String>,
602) -> Option<Vec<String>> {
603    let generic = top_level_generic_name(node)?;
604    let list = named_children(generic)
605        .into_iter()
606        .find(|c| c.kind() == "type_argument_list")?;
607    let args = named_children(list);
608    if args.is_empty() {
609        return None;
610    }
611    let mut descriptors = Vec::with_capacity(args.len());
612    for arg in args {
613        let base = base_type_identifier(Some(arg), src, true)?;
614        descriptors.push(if type_params.contains(&base) {
615            "*".to_string()
616        } else {
617            base
618        });
619    }
620    Some(descriptors)
621}
622
623// The type-parameter names a declaration introduces (`class Box<T>`,
624// `void Then<TSaga, TData>(...)`). Empty for every non-generic declaration.
625fn type_parameter_names(node: Node, src: &[u8]) -> HashSet<String> {
626    let mut names = HashSet::new();
627    let Some(list) = named_children(node)
628        .into_iter()
629        .find(|c| c.kind() == "type_parameter_list")
630    else {
631        return names;
632    };
633    for p in named_children(list) {
634        if p.kind() != "type_parameter" {
635            continue;
636        }
637        let name = match p.child_by_field_name("name") {
638            Some(n) => text(n, src),
639            None => text(p, src),
640        };
641        if !name.is_empty() {
642            names.insert(name);
643        }
644    }
645    names
646}
647
648// Same traversal as type_parameter_names, but order-preserving
649// (first-occurrence order, deduped) rather than a HashSet: this is the one
650// caller that serializes the list itself (DefRecord.type_params) rather than
651// only testing membership, and a HashSet's iteration order is undefined --
652// This must emit the type parameters in source order for the extract-dump
653// bytes to be stable.
654fn type_parameter_names_ordered(node: Node, src: &[u8]) -> Vec<String> {
655    let mut names: Vec<String> = Vec::new();
656    let Some(list) = named_children(node)
657        .into_iter()
658        .find(|c| c.kind() == "type_parameter_list")
659    else {
660        return names;
661    };
662    for p in named_children(list) {
663        if p.kind() != "type_parameter" {
664            continue;
665        }
666        let name = match p.child_by_field_name("name") {
667            Some(n) => text(n, src),
668            None => text(p, src),
669        };
670        if !name.is_empty() && !names.contains(&name) {
671            names.push(name);
672        }
673    }
674    names
675}
676
677// A member-access qualifier is a uses-member candidate when it is a plain
678// name ('identifier' or 'qualified_name'), a 'generic_name' ("Cache<T>.x" --
679// type args dropped for resolution, the same normalization outer_type_name
680// applies in type positions), OR a member_access_expression chain that
681// itself bottoms out at a plain name -- "Some.Namespace.MyEnum" in
682// "Some.Namespace.MyEnum.Member" parses as nested member_access_expression
683// (not qualified_name) since it's in expression position, so the chain is
684// flattened recursively into its full dotted text. walk() still recurses
685// into every level of the chain regardless (each level is its own
686// member_access_expression node), so a shorter window within the same chain
687// also gets its own (separately resolved) candidate -- the resolution ladder
688// (resolve.rs) silently drops whichever one doesn't clear its emission
689// tiers. A chain that never bottoms out at a plain name (a call, an
690// object-creation expression, an indexer, ...) simply yields no candidate at
691// that position, per the "never guess" rule.
692//
693// Returns (text, generic) rather than bare text: a type-argument list
694// anywhere in the qualifier is syntax only a
695// TYPE can carry (locals, fields, and properties cannot), so the resolver
696// uses `generic` as a type-certainty signal for non-enum member emission.
697fn member_qualifier_info(node: Option<Node>, src: &[u8]) -> Option<(String, bool)> {
698    let node = node?;
699    match node.kind() {
700        "identifier" | "qualified_name" => Some((text(node, src), false)),
701        "generic_name" => named_children(node)
702            .into_iter()
703            .find(|c| c.kind() == "identifier")
704            .map(|id| (text(id, src), true)),
705        "member_access_expression" => {
706            let inner = member_qualifier_info(node.child_by_field_name("expression"), src);
707            let name_node = node.child_by_field_name("name")?;
708            let (inner_text, inner_generic) = inner?;
709            if name_node.kind() == "generic_name" {
710                return named_children(name_node)
711                    .into_iter()
712                    .find(|c| c.kind() == "identifier")
713                    .map(|id| (format!("{inner_text}.{}", text(id, src)), true));
714            }
715            let name = text(name_node, src);
716            if name.is_empty() {
717                return None;
718            }
719            Some((format!("{inner_text}.{name}"), inner_generic))
720        }
721        _ => None,
722    }
723}
724
725// The argument count of the CALL this member access is the callee of, or
726// `None` when it is
727// not a callee at all.
728//
729// The parent test is deliberately narrow on both halves. The parent must be an
730// `invocation_expression`, and `node` must be its `function` field -- an access
731// sitting in the parent's ARGUMENT list ("Send(x.Payload)") has an
732// invocation_expression parent too, and inheriting that call's argument count
733// would be exactly the kind of borrowed fact stage 2's chain-tail hazard is
734// about. Everything else -- a property read, an element access, a member access
735// used as a value -- yields `None`, and an absent argCount is what keeps a
736// non-call out of the arity-matched extension tier entirely.
737//
738// Because the test reads `node`'s OWN parent, every ref site gets its own
739// answer for free: a flattened chain window and a promoted qualifier both
740// ask the question of their own position in the tree and can never inherit a
741// neighbour's count. Node identity is compared here by byte-range: two
742// distinct nodes of one tree cannot share both a start and an end byte AND a
743// kind at the same tree position.
744fn invocation_arg_count(node: Node) -> Option<usize> {
745    let parent = node.parent()?;
746    if parent.kind() != "invocation_expression" {
747        return None;
748    }
749    let function = parent.child_by_field_name("function")?;
750    if function.id() != node.id() {
751        return None;
752    }
753    let args = parent.child_by_field_name("arguments")?;
754    if args.kind() != "argument_list" {
755        return None;
756    }
757    Some(named_children(args).len())
758}
759
760fn push_ref(
761    refs: &mut Vec<RefRecord>,
762    kind: &str,
763    name: String,
764    line: usize,
765    ns: Option<String>,
766    qualified: Option<String>,
767    type_stack: &[String],
768) {
769    refs.push(RefRecord {
770        kind: kind.to_string(),
771        name,
772        qualified,
773        member: None,
774        line,
775        namespace: ns,
776        type_arg_count: None,
777        generic: false,
778        receiver_type: None,
779        arg_count: None,
780        receiver_args: None,
781        outer_types: type_stack.to_vec(),
782        args: None,
783        receiver_property_owner: None,
784        receiver_call_owner: None,
785        receiver_call_member: None,
786    });
787}
788
789// One 'ctor-param' ref per constructor parameter (see the
790// "constructor_declaration" walk arm). Kept separate from push_ref, like
791// push_member_ref is, rather than overloading it with a slot every other
792// caller would pass as None.
793fn push_ctor_param_ref(
794    refs: &mut Vec<RefRecord>,
795    name: String,
796    line: usize,
797    ns: String,
798    args: Option<Vec<String>>,
799    type_stack: &[String],
800) {
801    refs.push(RefRecord {
802        kind: "ctor-param".to_string(),
803        name,
804        qualified: None,
805        member: None,
806        line,
807        namespace: Some(ns),
808        type_arg_count: Some(args.as_ref().map_or(0, Vec::len)),
809        generic: false,
810        receiver_type: None,
811        arg_count: None,
812        receiver_args: None,
813        outer_types: type_stack.to_vec(),
814        args,
815        receiver_property_owner: None,
816        receiver_call_owner: None,
817        receiver_call_member: None,
818    });
819}
820
821// Member access needs the member name alongside the qualifier, which
822// push_ref has no slot for -- kept separate rather than overloading it with
823// an extra argument every other caller would have to pass as None.
824//
825// `receiver_type` is appended AFTER `generic`, and only ever
826// set for a BARE qualifier -- see the member_access_expression arm for the
827// guard. Keeping the guard at the call site (rather than here) is what makes
828// the chain-tail hazard structurally impossible: a flattened tail ("x.Foo"
829// as the qualifier of ".Bar") is dotted, so it can never be handed a
830// receiver fact it did not earn.
831//
832// `arg_count` is appended AFTER
833// `receiver_type` and computed the same way -- at the call site, from the ref's
834// OWN node -- for the same reason.
835//
836// `receiver_args` is appended LAST and travels
837// with the receiver FACT -- both halves come out of the same `Fact`, so a
838// receiverArgs can never land on a ref that has no receiver.
839//
840// `outer_types` is appended LAST of all, after `receiver_args` -- see
841// push_ref for what it carries.
842fn push_member_ref(
843    refs: &mut Vec<RefRecord>,
844    qualifier_text: &str,
845    member: String,
846    line: usize,
847    ns: String,
848    generic: bool,
849    receiver: Option<Fact>,
850    arg_count: Option<usize>,
851    type_stack: &[String],
852    property_owner: Option<String>,
853) {
854    // A call fact records the CALLEE it depends on and never a receiver type:
855    // the two are mutually exclusive on one ref, which is what lets every
856    // reader tell a recorded type from a lookup the resolver still owes.
857    let (receiver_type, receiver_args, receiver_call_owner, receiver_call_member) = match receiver {
858        Some(Fact {
859            type_name,
860            call: Some(member),
861            ..
862        }) => (None, None, Some(type_name), Some(member)),
863        Some(Fact {
864            type_name,
865            args,
866            call: None,
867        }) => (Some(type_name), args, None, None),
868        None => (None, None, None, None),
869    };
870    match qualifier_text.rfind('.') {
871        Some(dot) => refs.push(RefRecord {
872            kind: "uses-member".to_string(),
873            name: qualifier_text[dot + 1..].to_string(),
874            qualified: Some(qualifier_text.to_string()),
875            member: Some(member),
876            line,
877            namespace: Some(ns),
878            type_arg_count: None,
879            generic,
880            receiver_type,
881            arg_count,
882            receiver_args,
883            outer_types: type_stack.to_vec(),
884            args: None,
885            receiver_property_owner: property_owner.clone(),
886            receiver_call_owner: receiver_call_owner.clone(),
887            receiver_call_member: receiver_call_member.clone(),
888        }),
889        None => refs.push(RefRecord {
890            kind: "uses-member".to_string(),
891            name: qualifier_text.to_string(),
892            qualified: None,
893            member: Some(member),
894            line,
895            namespace: Some(ns),
896            type_arg_count: None,
897            generic,
898            receiver_type,
899            arg_count,
900            receiver_args,
901            outer_types: type_stack.to_vec(),
902            args: None,
903            receiver_property_owner: property_owner,
904            receiver_call_owner,
905            receiver_call_member,
906        }),
907    }
908}
909
910// Line comes from the type node itself, not the enclosing declaration -- a
911// declaration's own span starts at its attribute list when it has one,
912// which would otherwise point a reader at the attribute line instead of
913// the line the type reference is actually on.
914fn record_single_type(
915    node: Option<Node>,
916    kind: &str,
917    ns: &str,
918    type_stack: &[String],
919    src: &[u8],
920    refs: &mut Vec<RefRecord>,
921) {
922    let Some(node) = node else {
923        return;
924    };
925    if node.kind() == "tuple_type" {
926        for el in named_children(node) {
927            if el.kind() != "tuple_element" {
928                continue;
929            }
930            record_single_type(
931                el.child_by_field_name("type"),
932                kind,
933                ns,
934                type_stack,
935                src,
936                refs,
937            );
938        }
939        return;
940    }
941    let Some(raw) = outer_type_name(node, src) else {
942        return;
943    };
944    let line = node.start_position().row + 1;
945    let arity = Some(type_argument_arity(Some(node), src).unwrap_or(0));
946    match raw.rfind('.') {
947        Some(dot) => push_ref(
948            refs,
949            kind,
950            raw[dot + 1..].to_string(),
951            line,
952            Some(ns.to_string()),
953            Some(raw.clone()),
954            type_stack,
955        ),
956        None => push_ref(
957            refs,
958            kind,
959            raw.clone(),
960            line,
961            Some(ns.to_string()),
962            None,
963            type_stack,
964        ),
965    }
966    if let Some(last) = refs.last_mut() {
967        last.type_arg_count = arity;
968    }
969}
970
971fn record_base_list(
972    node: Node,
973    ns: &str,
974    type_stack: &[String],
975    src: &[u8],
976    refs: &mut Vec<RefRecord>,
977) {
978    let Some(bl) = named_children(node)
979        .into_iter()
980        .find(|c| c.kind() == "base_list")
981    else {
982        return;
983    };
984    for child in named_children(bl) {
985        if child.kind() == "argument_list" {
986            continue;
987        }
988        if child.kind() == "primary_constructor_base_type" {
989            record_single_type(
990                child.child_by_field_name("type"),
991                "inherits",
992                ns,
993                type_stack,
994                src,
995                refs,
996            );
997            continue;
998        }
999        record_single_type(Some(child), "inherits", ns, type_stack, src, refs);
1000    }
1001}
1002
1003// Deliberately NOT public_method_names: that one strips a trailing "Async"
1004// and dedupes across the whole file for purpose-signature compactness. A
1005// graph def needs the real name, unabridged.
1006fn raw_method_names(node: Node, src: &[u8], kind: &str) -> Vec<String> {
1007    let Some(body) = node.child_by_field_name("body") else {
1008        return Vec::new();
1009    };
1010    named_children(body)
1011        .into_iter()
1012        .filter(|c| is_recorded_method(*c, src, kind))
1013        .map(|c| declared_name(c, src))
1014        .filter(|n| !n.is_empty())
1015        .collect()
1016}
1017
1018// The one predicate `methods` and `method_returns` share, so the two lists
1019// can never drift apart: method_returns is a map PARALLEL to methods, not a
1020// second, wider survey of the type.
1021fn is_recorded_method(node: Node, src: &[u8], kind: &str) -> bool {
1022    node.kind() == "method_declaration" && (kind == "interface" || is_public(node, src))
1023}
1024
1025// Declared property names, source order, deduped. Indexers are
1026// a different grammar node (indexer_declaration) so they are excluded by
1027// construction; expression-bodied properties are property_declaration like
1028// any other, so they are included. No accessibility filter -- see
1029// DefRecord::properties.
1030fn raw_property_names(node: Node, src: &[u8]) -> Vec<String> {
1031    let Some(body) = node.child_by_field_name("body") else {
1032        return Vec::new();
1033    };
1034    let mut seen: HashSet<String> = HashSet::new();
1035    let mut names = Vec::new();
1036    for c in named_children(body) {
1037        if c.kind() != "property_declaration" {
1038            continue;
1039        }
1040        let name = declared_name(c, src);
1041        if name.is_empty() || !seen.insert(name.clone()) {
1042            continue;
1043        }
1044        names.push(name);
1045    }
1046    names
1047}
1048
1049// (name, fact) pairs for exactly the properties `raw_property_names`
1050// records, in the same source order and under the same dedup -- a property
1051// whose declared type yields no fact simply has no entry. The fact is the SAME
1052// shape a receiver fact carries, which is what lets resolution treat a property
1053// hop exactly like a field- or local-typed one.
1054fn raw_property_types(
1055    node: Node,
1056    src: &[u8],
1057    type_params: &HashSet<String>,
1058) -> Vec<(String, Fact)> {
1059    let Some(body) = node.child_by_field_name("body") else {
1060        return Vec::new();
1061    };
1062    let mut seen: HashSet<String> = HashSet::new();
1063    let mut pairs = Vec::new();
1064    for c in named_children(body) {
1065        if c.kind() != "property_declaration" {
1066            continue;
1067        }
1068        let name = declared_name(c, src);
1069        if name.is_empty() || !seen.insert(name.clone()) {
1070            continue;
1071        }
1072        if let Some(fact) = type_fact(c.child_by_field_name("type"), src, type_params) {
1073            pairs.push((name, fact));
1074        }
1075    }
1076    pairs
1077}
1078
1079// Declared field names, source order, deduped -- every
1080// declarator of every field_declaration ("private int a, b;" contributes
1081// both). event_field_declaration is a distinct node type and is NOT a field
1082// for this purpose.
1083fn raw_field_names(node: Node, src: &[u8]) -> Vec<String> {
1084    let Some(body) = node.child_by_field_name("body") else {
1085        return Vec::new();
1086    };
1087    let mut seen: HashSet<String> = HashSet::new();
1088    let mut names = Vec::new();
1089    for c in named_children(body) {
1090        if c.kind() != "field_declaration" {
1091            continue;
1092        }
1093        let Some(vd) = named_children(c)
1094            .into_iter()
1095            .find(|k| k.kind() == "variable_declaration")
1096        else {
1097            continue;
1098        };
1099        for decl in named_children(vd) {
1100            if decl.kind() != "variable_declarator" {
1101                continue;
1102            }
1103            let Some(name) = decl.child_by_field_name("name").map(|n| text(n, src)) else {
1104                continue;
1105            };
1106            if name.is_empty() || !seen.insert(name.clone()) {
1107                continue;
1108            }
1109            names.push(name);
1110        }
1111    }
1112    names
1113}
1114
1115// (name, returnTypeName) pairs in first-declaration order, for
1116// exactly the methods `raw_method_names` records. FIRST declaration of a name
1117// claims the slot outright: a later overload with a different return type is
1118// ignored, and a first declaration whose return type yields no fact (void,
1119// var, a predefined type) BLOCKS the name rather than letting a later
1120// overload's return type stand in for it -- picking a non-first overload
1121// would be a guess.
1122fn raw_method_returns(node: Node, src: &[u8], kind: &str) -> Vec<(String, String)> {
1123    let Some(body) = node.child_by_field_name("body") else {
1124        return Vec::new();
1125    };
1126    let mut seen: HashSet<String> = HashSet::new();
1127    let mut pairs = Vec::new();
1128    for c in named_children(body) {
1129        if !is_recorded_method(c, src, kind) {
1130            continue;
1131        }
1132        let name = declared_name(c, src);
1133        if name.is_empty() || !seen.insert(name.clone()) {
1134            continue;
1135        }
1136        if let Some(returns) = base_type_identifier(c.child_by_field_name("returns"), src, false) {
1137            pairs.push((name, returns));
1138        }
1139    }
1140    pairs
1141}
1142
1143// The two halves of an extension method's acceptable argument COUNT, read off
1144// the parameter list as
1145// written:
1146//   arity_min -- non-this parameters a caller cannot leave out: no default
1147//     value AND no `params` modifier.
1148//   arity_max -- the total non-this parameter count, or -1 (unbounded) when the
1149//     trailing parameter is a `params` array.
1150// A single exact arity would under-match every optional-parameter and
1151// `params` call site and -- worse -- make two classes look like ONE candidate
1152// when only one of them could actually bind the call: the false-uniqueness
1153// shape.
1154//
1155// The loop reads ALL children, not named_children, because tree-sitter-c-sharp
1156// does NOT wrap a `params` parameter in a `parameter` node: it emits a bare
1157// anonymous `params` token followed by that parameter's type and name nodes as
1158// direct children of the parameter_list. A named-children count therefore reads
1159// `(this T t, params X[] xs)` as THREE parameters and a `parameter`-node count
1160// reads it as one; the token itself is the only reliable signal, and since C#
1161// requires `params` to be last, seeing it at all means unbounded.
1162fn extension_arity_range(parameters: Node, src: &[u8]) -> (usize, i64) {
1163    let mut total: i64 = 0;
1164    let mut min: usize = 0;
1165    let mut unbounded = false;
1166    let mut seen_this = false;
1167    let mut cursor = parameters.walk();
1168    for c in parameters.children(&mut cursor) {
1169        if c.kind() == "params" {
1170            unbounded = true;
1171            continue;
1172        }
1173        if c.kind() != "parameter" {
1174            continue;
1175        }
1176        // The first `parameter` node IS the this-parameter (the caller has
1177        // already verified its `this` modifier), and a flattened `params` group
1178        // can never occupy that slot.
1179        if !seen_this {
1180            seen_this = true;
1181            continue;
1182        }
1183        total += 1;
1184        let is_params = named_children(c)
1185            .iter()
1186            .any(|m| m.kind() == "modifier" && text(*m, src) == "params");
1187        if is_params {
1188            unbounded = true;
1189        } else if !has_default_value(c) {
1190            min += 1;
1191        }
1192    }
1193    (min, if unbounded { -1 } else { total })
1194}
1195
1196// A default value is an `=` token among the parameter's own children.
1197fn has_default_value(parameter: Node) -> bool {
1198    (0..parameter.child_count() as u32).any(|i| parameter.child(i).map(|c| c.kind()) == Some("="))
1199}
1200
1201// Extension methods this type declares: every method whose FIRST
1202// parameter carries the `this` modifier, as
1203// {name, thisType, arityMin, arityMax, thisArgs?} in source order, deduped by
1204// the (name, thisType, arityMin, arityMax) QUADRUPLE -- the key the resolver's
1205// bucket lookup and range filter are built from. Overloads that differ only in
1206// their later parameters ("Render(this Widget w)" /
1207// "Render(this Widget w, int d)") are two entries with two ranges, and a call
1208// binds to whichever one its argument count actually falls inside.
1209//
1210// `this_args` (the generic amendment) is present only when the this-parameter
1211// type is generic, and records that type's TOP-LEVEL type arguments with this
1212// method's and this class's own type parameters written as "*" -- a wildcard,
1213// because an extension declared over `EventPipelineBinder<TSaga, TData>`
1214// genuinely accepts any binding, while one declared over
1215// `IDictionary<string, object>` accepts exactly that one.
1216//
1217// Two deliberate NON-filters, both the same argument. The enclosing class is
1218// not checked for `static`, and neither is the method: C# already disallows a
1219// `this` parameter anywhere but a static method of a static non-generic class,
1220// so the parameter modifier IS the discriminator and the parser's output is the
1221// truth -- a filter could only ever throw away a fact, never add one. And
1222// unlike `methods` (public members only), there is NO accessibility filter:
1223// `internal static class FooExtensions` is the single most common shape this
1224// feature exists for, and an extension method is usable wherever it is VISIBLE
1225// -- which the resolver bounds by the using/namespace admission rule, not by a
1226// modifier read off the declaration.
1227//
1228// `this` on a non-first parameter is not an extension method (and does not
1229// compile); the first-parameter-only read is what excludes it. A `parameter`
1230// node can carry MORE than one modifier (`this ref T x`), so EVERY named
1231// `modifier` child is scanned.
1232fn raw_extension_methods(node: Node, src: &[u8]) -> Vec<ExtensionMethod> {
1233    let Some(body) = node.child_by_field_name("body") else {
1234        return Vec::new();
1235    };
1236    let class_type_params = type_parameter_names(node, src);
1237    let mut seen: HashSet<String> = HashSet::new();
1238    let mut entries = Vec::new();
1239    for c in named_children(body) {
1240        if c.kind() != "method_declaration" {
1241            continue;
1242        }
1243        let Some(parameters) = c.child_by_field_name("parameters") else {
1244            continue;
1245        };
1246        let params = named_children(parameters);
1247        let Some(first) = params.first().copied() else {
1248            continue;
1249        };
1250        if first.kind() != "parameter" {
1251            continue;
1252        }
1253        if !named_children(first)
1254            .iter()
1255            .any(|m| m.kind() == "modifier" && text(*m, src) == "this")
1256        {
1257            continue;
1258        }
1259        let name = declared_name(c, src);
1260        // keep_predefined: `this string s` records "string". Same array/generic/
1261        // qualified collapsing as every other type fact, so `this Widget[] a`
1262        // records "Widget" -- matching what a `Widget[]` local's receiver fact
1263        // records, which is the whole point of the pair being compared by name.
1264        let type_node = first.child_by_field_name("type");
1265        let Some(this_type) = base_type_identifier(type_node, src, true) else {
1266            continue;
1267        };
1268        if name.is_empty() {
1269            continue;
1270        }
1271        let (arity_min, arity_max) = extension_arity_range(parameters, src);
1272        if !seen.insert(format!("{name} {this_type} {arity_min} {arity_max}")) {
1273            continue;
1274        }
1275        let mut type_params = class_type_params.clone();
1276        type_params.extend(type_parameter_names(c, src));
1277        let this_args = generic_arg_descriptors(type_node, src, &type_params);
1278        entries.push(ExtensionMethod {
1279            name,
1280            this_type,
1281            arity_min,
1282            arity_max,
1283            this_args,
1284        });
1285    }
1286    entries
1287}
1288
1289// The DIRECT base-type names this declaration lists, in source order and
1290// deduped -- the same base_list
1291// traversal `record_base_list` walks for its `inherits` refs, reduced to a base
1292// IDENTIFIER (generic arguments stripped, a qualified name cut to its last
1293// segment) because these names are RESOLVED, not matched: the resolver hands
1294// each one back through the ordinary ladder as a bare name, exactly like a
1295// stage-2 receiver fact.
1296//
1297// Recorded for every kind record_type_def handles, not just the four that emit
1298// `inherits` refs: an enum's `: byte` and a delegate's absent base list both
1299// reduce to nothing on their own (a predefined type yields no identifier), so
1300// the extra generality costs no bytes and needs no per-kind branch.
1301fn raw_base_names(node: Node, src: &[u8]) -> Vec<String> {
1302    let Some(bl) = named_children(node)
1303        .into_iter()
1304        .find(|c| c.kind() == "base_list")
1305    else {
1306        return Vec::new();
1307    };
1308    let mut names: Vec<String> = Vec::new();
1309    for child in named_children(bl) {
1310        if child.kind() == "argument_list" {
1311            continue;
1312        }
1313        let type_node = if child.kind() == "primary_constructor_base_type" {
1314            child.child_by_field_name("type")
1315        } else {
1316            Some(child)
1317        };
1318        if let Some(name) = base_type_identifier(type_node, src, false) {
1319            if !names.contains(&name) {
1320                names.push(name);
1321            }
1322        }
1323    }
1324    names
1325}
1326
1327// The generic-argument descriptors
1328// raw_base_names throws away, keyed by the same bare base identifier,
1329// first-declaration wins (mirroring raw_base_names's own first-wins dedup).
1330// `type_params` here is the DECLARING type's own type parameters, so
1331// `class MongoRepository<T> : IRepository<T>` records `[("IRepository",
1332// ["*"])]` -- a wildcard pass-through, the ctor-DI resolver's signal that
1333// this is an OPEN-generic implementation -- while `class SpecificRepo :
1334// IRepository<User>` records `[("IRepository", ["User"])]`, a closed one. A
1335// base with no type-argument list at all contributes no entry.
1336fn raw_base_generic_args(
1337    node: Node,
1338    src: &[u8],
1339    type_params: &HashSet<String>,
1340) -> Vec<(String, Vec<String>)> {
1341    let Some(bl) = named_children(node)
1342        .into_iter()
1343        .find(|c| c.kind() == "base_list")
1344    else {
1345        return Vec::new();
1346    };
1347    let mut out: Vec<(String, Vec<String>)> = Vec::new();
1348    for child in named_children(bl) {
1349        if child.kind() == "argument_list" {
1350            continue;
1351        }
1352        let type_node = if child.kind() == "primary_constructor_base_type" {
1353            child.child_by_field_name("type")
1354        } else {
1355            Some(child)
1356        };
1357        let Some(name) = base_type_identifier(type_node, src, false) else {
1358            continue;
1359        };
1360        if out.iter().any(|(k, _)| k == &name) {
1361            continue;
1362        }
1363        if let Some(args) = generic_arg_descriptors(type_node, src, type_params) {
1364            out.push((name, args));
1365        }
1366    }
1367    out
1368}
1369
1370// The attribute short names that MARK a method as a test
1371// -- xUnit's Fact/Theory, NUnit's Test/TestCase/TestCaseSource (Theory is
1372// shared with xUnit), MSTest's TestMethod/DataTestMethod -- split in two
1373// because only MSTest's pair is GATED: `[TestMethod]` inside a class that does
1374// not carry `[TestClass]` is not a discovered test, while xUnit has no
1375// class-level attribute at all and NUnit's `[TestFixture]` is optional.
1376// Data-source attributes (InlineData, MemberData, ClassData, DataRow,
1377// DynamicData), lifecycle hooks (SetUp, TearDown, OneTimeSetUp,
1378// TestInitialize, ...) and the class-level containers themselves are absent
1379// from both sets, so none of them can ever mark a method.
1380const DIRECT_TEST_ATTRIBUTES: &[&str] = &["Fact", "Theory", "Test", "TestCase", "TestCaseSource"];
1381const MSTEST_TEST_ATTRIBUTES: &[&str] = &["TestMethod", "DataTestMethod"];
1382const MSTEST_CLASS_ATTRIBUTE: &str = "TestClass";
1383const TEST_METHOD_KINDS: &[&str] = &["class", "struct", "record"];
1384const ATTRIBUTE_SUFFIX: &str = "Attribute";
1385
1386// Every attribute name written on one declaration, normalized to the spellings
1387// the sets above are keyed by. An `attribute_list` is a direct named child of
1388// the declaration it decorates (type or method) and holds one `attribute` child
1389// per comma-separated entry inside a single bracket pair, optionally preceded
1390// by an `attribute_target_specifier` (`[method: Fact]`) -- a distinct node type,
1391// so filtering on `attribute` skips it without a special case. The attribute's
1392// `name` field is an `identifier` or a `qualified_name`; only the segment after
1393// the last dot names the type, and C# lets a usage site drop the `Attribute`
1394// suffix, so both spellings of the same name are offered and either one
1395// matching is a match.
1396fn attribute_names(node: Node, src: &[u8]) -> HashSet<String> {
1397    let mut names = HashSet::new();
1398    for list in named_children(node) {
1399        if list.kind() != "attribute_list" {
1400            continue;
1401        }
1402        for attr in named_children(list) {
1403            if attr.kind() != "attribute" {
1404                continue;
1405            }
1406            let Some(name_node) = attr.child_by_field_name("name") else {
1407                continue;
1408            };
1409            let text = text(name_node, src);
1410            if text.is_empty() {
1411                continue;
1412            }
1413            let last = match text.rfind('.') {
1414                Some(i) => &text[i + 1..],
1415                None => &text[..],
1416            };
1417            if last.is_empty() {
1418                continue;
1419            }
1420            names.insert(last.to_string());
1421            if last.len() > ATTRIBUTE_SUFFIX.len() && last.ends_with(ATTRIBUTE_SUFFIX) {
1422                names.insert(last[..last.len() - ATTRIBUTE_SUFFIX.len()].to_string());
1423            }
1424        }
1425    }
1426    names
1427}
1428
1429// class/struct/record only. An interface body cannot host a discovered test (no
1430// runner instantiates one) and an enum body has no methods at all, so the kind
1431// gate costs one lookup and keeps both out by construction rather than relying
1432// on their bodies happening to be empty of matches.
1433//
1434// A local function inside a method body is not a method_declaration at type
1435// body level, so the flat named-children scan every other member fact uses
1436// excludes it for free; a nested type computes its own list on its own visit
1437// and never inherits an enclosing `[TestClass]`.
1438fn raw_test_methods(node: Node, src: &[u8], kind: &str) -> Vec<String> {
1439    if !TEST_METHOD_KINDS.contains(&kind) {
1440        return Vec::new();
1441    }
1442    let Some(body) = node.child_by_field_name("body") else {
1443        return Vec::new();
1444    };
1445    let mstest = attribute_names(node, src).contains(MSTEST_CLASS_ATTRIBUTE);
1446    let mut seen: HashSet<String> = HashSet::new();
1447    let mut names = Vec::new();
1448    for c in named_children(body) {
1449        if c.kind() != "method_declaration" {
1450            continue;
1451        }
1452        let attrs = attribute_names(c, src);
1453        let marked = attrs.iter().any(|a| {
1454            DIRECT_TEST_ATTRIBUTES.contains(&a.as_str())
1455                || (mstest && MSTEST_TEST_ATTRIBUTES.contains(&a.as_str()))
1456        });
1457        if !marked {
1458            continue;
1459        }
1460        let name = declared_name(c, src);
1461        if name.is_empty() || !seen.insert(name.clone()) {
1462            continue;
1463        }
1464        names.push(name);
1465    }
1466    names
1467}
1468
1469// A flat "namespace.name" id collides two ways a real C# id never does:
1470// nested types (is `Outer` a namespace segment or a type?) and an unrelated
1471// namespace-level type sharing a dotted path with someone else's nested
1472// type. Nested types get the CLR's own answer: joined with `+` onto their
1473// enclosing type chain, never `.`.
1474fn type_id(name: &str, ns: &str, type_stack: &[String]) -> String {
1475    if !type_stack.is_empty() {
1476        let prefix = if ns.is_empty() {
1477            String::new()
1478        } else {
1479            format!("{ns}.")
1480        };
1481        format!("{prefix}{}+{name}", type_stack.join("+"))
1482    } else if !ns.is_empty() {
1483        format!("{ns}.{name}")
1484    } else {
1485        name.to_string()
1486    }
1487}
1488
1489// Every member this type declares: methods regardless of
1490// accessibility, properties, fields and events, each with the line its own
1491// name sits on. No accessibility filter and no dedup -- two overloads are two
1492// declarations at two lines, and both are answers.
1493fn record_declared_members(node: Node, owner_id: &str, src: &[u8], names: &mut Vec<NameRecord>) {
1494    let Some(body) = node.child_by_field_name("body") else {
1495        return;
1496    };
1497    for c in named_children(body) {
1498        match c.kind() {
1499            "method_declaration" => push_declared_name(
1500                c.child_by_field_name("name"),
1501                "method",
1502                owner_id,
1503                src,
1504                names,
1505            ),
1506            "property_declaration" => push_declared_name(
1507                c.child_by_field_name("name"),
1508                "property",
1509                owner_id,
1510                src,
1511                names,
1512            ),
1513            "event_declaration" => {
1514                push_declared_name(c.child_by_field_name("name"), "event", owner_id, src, names)
1515            }
1516            "field_declaration" => push_declarators(c, "field", owner_id, src, names),
1517            "event_field_declaration" => push_declarators(c, "event", owner_id, src, names),
1518            _ => {}
1519        }
1520    }
1521}
1522
1523fn push_declared_name(
1524    name_node: Option<Node>,
1525    kind: &str,
1526    owner_id: &str,
1527    src: &[u8],
1528    names: &mut Vec<NameRecord>,
1529) {
1530    let Some(name_node) = name_node else {
1531        return;
1532    };
1533    let name = text(name_node, src);
1534    if name.is_empty() {
1535        return;
1536    }
1537    names.push(NameRecord {
1538        name,
1539        kind: kind.to_string(),
1540        line: name_node.start_position().row + 1,
1541        owner: owner_id.to_string(),
1542    });
1543}
1544
1545fn push_declarators(
1546    node: Node,
1547    kind: &str,
1548    owner_id: &str,
1549    src: &[u8],
1550    names: &mut Vec<NameRecord>,
1551) {
1552    let Some(vd) = named_children(node)
1553        .into_iter()
1554        .find(|k| k.kind() == "variable_declaration")
1555    else {
1556        return;
1557    };
1558    for decl in named_children(vd) {
1559        if decl.kind() != "variable_declarator" {
1560            continue;
1561        }
1562        push_declared_name(decl.child_by_field_name("name"), kind, owner_id, src, names);
1563    }
1564}
1565
1566// `type_params` is the declaring type's own type-parameter set --
1567// empty (`&EMPTY_TYPE_PARAMS`, a shared static) for the two callers that never
1568// carry any in this grammar's terms, enum and delegate declarations, so this
1569// function never has to branch on caller identity to know what to pass.
1570fn record_type_def(
1571    node: Node,
1572    ns: &str,
1573    kind: &str,
1574    type_stack: &[String],
1575    src: &[u8],
1576    defs: &mut Vec<DefRecord>,
1577    names: &mut Vec<NameRecord>,
1578    type_params: &HashSet<String>,
1579) {
1580    let name = declared_name(node, src);
1581    if name.is_empty() {
1582        return;
1583    }
1584    let id = type_id(&name, ns, type_stack);
1585    record_declared_members(node, &id, src, names);
1586    // Field order is significant (graph.rs's FragDef serializes these bytes):
1587    // id, name, namespace, kind, line, methods, then the member-fact additions
1588    // appended LAST in declaration order -- properties, fields, methodReturns
1589    // -- then extensionMethods and (for the inheritance veto) bases, then
1590    // type_params and base_generic_args, then testMethods, then propertyTypes.
1591    // Each is omitted when empty, so a type with none of them serializes
1592    // exactly as it did before those additions.
1593    defs.push(DefRecord {
1594        id,
1595        name,
1596        namespace: ns.to_string(),
1597        kind: kind.to_string(),
1598        line: node.start_position().row + 1,
1599        methods: raw_method_names(node, src, kind),
1600        properties: raw_property_names(node, src),
1601        fields: raw_field_names(node, src),
1602        method_returns: raw_method_returns(node, src, kind),
1603        extension_methods: raw_extension_methods(node, src),
1604        bases: raw_base_names(node, src),
1605        type_params: type_parameter_names_ordered(node, src),
1606        base_generic_args: raw_base_generic_args(node, src, type_params),
1607        test_methods: raw_test_methods(node, src, kind),
1608        property_types: raw_property_types(node, src, type_params),
1609        end_line: node.end_position().row + 1,
1610    });
1611}
1612
1613// Enum members are cheap to record on the same walk. Each member becomes
1614// its own def, id'd as "<EnumId>.<Member>" -- appending with "." even when
1615// EnumId itself carries a "+"-joined nested-type suffix, so a member id can
1616// never collide with the "+"-joined nested-type scheme.
1617fn record_enum_members(
1618    node: Node,
1619    ns: &str,
1620    type_stack: &[String],
1621    src: &[u8],
1622    defs: &mut Vec<DefRecord>,
1623) {
1624    let name = declared_name(node, src);
1625    if name.is_empty() {
1626        return;
1627    }
1628    let enum_id = type_id(&name, ns, type_stack);
1629    let Some(body) = node.child_by_field_name("body") else {
1630        return;
1631    };
1632    for member in named_children(body) {
1633        if member.kind() != "enum_member_declaration" {
1634            continue;
1635        }
1636        let member_name = declared_name(member, src);
1637        if member_name.is_empty() {
1638            continue;
1639        }
1640        defs.push(DefRecord {
1641            id: format!("{enum_id}.{member_name}"),
1642            name: member_name,
1643            namespace: ns.to_string(),
1644            kind: "enum-member".to_string(),
1645            line: member.start_position().row + 1,
1646            methods: Vec::new(),
1647            properties: Vec::new(),
1648            fields: Vec::new(),
1649            method_returns: Vec::new(),
1650            extension_methods: Vec::new(),
1651            bases: Vec::new(),
1652            type_params: Vec::new(),
1653            base_generic_args: Vec::new(),
1654            test_methods: Vec::new(),
1655            property_types: Vec::new(),
1656            end_line: member.end_position().row + 1,
1657        });
1658    }
1659}
1660
1661fn record_using(node: Node, src: &[u8], usings: &mut Vec<UsingRecord>, refs: &mut Vec<RefRecord>) {
1662    // `global using ...;` is parsed with a leading unnamed `global` token --
1663    // it's not a field, so child_by_field_name can't see it; child(0) can.
1664    let is_global = node.child(0).map(|n| n.kind()) == Some("global");
1665    // Covers `using X.Y;`, `using static X.Y.Z;`, and `using Alias = X.Y.Z;`
1666    // -- in every case the last named child is the actual imported path.
1667    // Alias directives (exactly 2 named children: [aliasName, target]) are
1668    // kept distinct from plain/static imports because resolution
1669    // short-circuits on them instead of treating the alias name as an
1670    // ordinary using.
1671    let kids = named_children(node);
1672    let Some(target) = kids.last() else {
1673        return;
1674    };
1675    let text_val = text(*target, src).trim().to_string();
1676    if text_val.is_empty() {
1677        return;
1678    }
1679    if kids.len() == 2 {
1680        let alias_name = text(kids[0], src).trim().to_string();
1681        if !alias_name.is_empty() {
1682            usings.push(UsingRecord::Alias {
1683                alias: alias_name,
1684                target: text_val.clone(),
1685                global: is_global,
1686            });
1687        }
1688    } else {
1689        usings.push(UsingRecord::Plain {
1690            text: text_val.clone(),
1691            global: is_global,
1692        });
1693    }
1694    push_ref(
1695        refs,
1696        "imports",
1697        text_val,
1698        node.start_position().row + 1,
1699        None,
1700        None,
1701        &[],
1702    );
1703}
1704
1705// ---------------------------------------------------------------------------
1706// Receiver facts.
1707//
1708// Two flat name->type tables per member ref: one for the enclosing TYPE
1709// (fields + primary-constructor parameters, which are in scope for the whole
1710// body) and one for the enclosing MEMBER declaration (its parameters and
1711// every local declared anywhere inside it). The member table shadows the type
1712// table: a local or parameter (innermost) wins over a field.
1713//
1714// The member table is deliberately FLAT -- a permitted simplification -- so
1715// two sibling blocks each declaring `x` with a different type
1716// collapse to one CONFLICTED entry rather than to a per-block answer. A
1717// conflicted entry, and an entry whose type yields no fact at all
1718// (`var x = SomeCall()`, `string s`), are both stored as `None`: the name is
1719// taken, and nothing vouches for its type, so no fact is produced. Storing
1720// them rather than omitting them is what keeps a local from silently falling
1721// through to a same-named field of a different type.
1722// ---------------------------------------------------------------------------
1723
1724/// One receiver fact: the declared type NAME plus that type's top-level
1725/// type-argument descriptors when it carried any. Two facts agree only when
1726/// BOTH halves do, so `Box<int> a` and `Box<string> a` in sibling blocks
1727/// conflict exactly like two different type names would.
1728#[derive(Clone, PartialEq, Eq)]
1729pub struct Fact {
1730    /// The type name value.
1731    pub type_name: String,
1732    /// The args value.
1733    pub args: Option<Vec<String>>,
1734    /// When set, the name's type is whatever the method of this name returns
1735    /// on the type `type_name` stands for, a lookup only the resolver can do.
1736    /// Part of the equality the table compares, so `var x = A.Make()` and
1737    /// `var x = A.Build()` in sibling blocks conflict exactly like two
1738    /// different type names would.
1739    pub call: Option<String>,
1740}
1741
1742// name -> `Some(fact)` when exactly one fact vouches for it, `None` when the
1743// name is taken but nothing does. Both the table and its "taken but unknown"
1744// entries live in one map; the `Option<Fact>` value is that empty slot.
1745type FactTable = HashMap<String, Option<Fact>>;
1746
1747fn add_fact(table: &mut FactTable, name: Option<String>, fact: Option<Fact>) {
1748    let Some(name) = name.filter(|n| !n.is_empty()) else {
1749        return;
1750    };
1751    match table.get(&name) {
1752        None => {
1753            table.insert(name, fact);
1754        }
1755        Some(existing) => {
1756            if *existing != fact {
1757                table.insert(name, None);
1758            }
1759        }
1760    }
1761}
1762
1763// The declared-type half stays `keep_predefined = false` --
1764// a `string s` local still vouches for nothing.
1765fn type_fact(type_node: Option<Node>, src: &[u8], type_params: &HashSet<String>) -> Option<Fact> {
1766    let type_name = base_type_identifier(type_node, src, false)?;
1767    let args = generic_arg_descriptors(type_node, src, type_params);
1768    Some(Fact {
1769        type_name,
1770        args,
1771        call: None,
1772    })
1773}
1774
1775// `var x = new T(...)` -- the ONLY shape where an initializer is consulted.
1776// An explicitly typed declaration is answered by its own type node, so
1777// `object o = new Widget()` records `object` (a predefined type: no fact),
1778// never `Widget`.
1779fn new_expression_fact(
1780    declarator: Node,
1781    src: &[u8],
1782    type_params: &HashSet<String>,
1783) -> Option<Fact> {
1784    let init = named_children(declarator)
1785        .into_iter()
1786        .find(|c| c.kind() == "object_creation_expression")?;
1787    type_fact(init.child_by_field_name("type"), src, type_params)
1788}
1789
1790// Fields and primary-constructor parameters of one type declaration. Direct
1791// children only: a nested type gets its OWN table, never the enclosing
1792// type's, because a nested type cannot reach an outer instance field.
1793fn collect_type_facts(node: Node, src: &[u8], type_params: &HashSet<String>) -> FactTable {
1794    let mut table = FactTable::new();
1795    for kid in named_children(node) {
1796        if kid.kind() != "parameter_list" {
1797            continue;
1798        }
1799        for p in named_children(kid) {
1800            if p.kind() != "parameter" {
1801                continue;
1802            }
1803            add_fact(
1804                &mut table,
1805                p.child_by_field_name("name").map(|n| text(n, src)),
1806                type_fact(p.child_by_field_name("type"), src, type_params),
1807            );
1808        }
1809    }
1810    let Some(body) = node.child_by_field_name("body") else {
1811        return table;
1812    };
1813    collect_declared_member_facts(&named_children(body), src, type_params, &mut table);
1814    table
1815}
1816
1817// The declared-member half of `collect_type_facts`, over a plain member slice
1818// -- shared with the ERROR-recovery path, where the same declarations exist as
1819// flat siblings because the parser never built the type node that would have
1820// wrapped them. A property's declared type vouches for its own name
1821// exactly like a field's does: one table, one conflict rule, and an indexer is
1822// a different grammar node so it is excluded by construction.
1823fn collect_declared_member_facts(
1824    members: &[Node],
1825    src: &[u8],
1826    type_params: &HashSet<String>,
1827    table: &mut FactTable,
1828) {
1829    for c in members {
1830        if c.kind() == "property_declaration" {
1831            let name = declared_name(*c, src);
1832            add_fact(
1833                table,
1834                Some(name),
1835                type_fact(c.child_by_field_name("type"), src, type_params),
1836            );
1837            continue;
1838        }
1839        if c.kind() != "field_declaration" {
1840            continue;
1841        }
1842        let Some(vd) = named_children(*c)
1843            .into_iter()
1844            .find(|k| k.kind() == "variable_declaration")
1845        else {
1846            continue;
1847        };
1848        let fact = type_fact(vd.child_by_field_name("type"), src, type_params);
1849        for decl in named_children(vd) {
1850            if decl.kind() != "variable_declarator" {
1851                continue;
1852            }
1853            add_fact(
1854                table,
1855                decl.child_by_field_name("name").map(|n| text(n, src)),
1856                fact.clone(),
1857            );
1858        }
1859    }
1860}
1861
1862// Every `parameter` (explicitly typed lambda parameters included -- an
1863// implicit_parameter has no type node and therefore no fact) and every
1864// `variable_declaration` in the member's whole subtree, including the ones
1865// inside nested lambdas and local functions. One pass, one table.
1866//
1867// `class_type_params` is the enclosing type's parameter set; this member's own
1868// are unioned onto it, which is what turns
1869// `EventPipelineBinder<FutureState, TM>` into ["FutureState", "*"] when TM is
1870// the method's own parameter. A local function's own type parameters are
1871// deliberately NOT unioned in -- its locals land in this same flat table, and
1872// a local-function parameter used as a type argument records its literal name
1873// instead, which simply fails to unify. Narrower than the language, never
1874// wider.
1875fn collect_member_facts(
1876    node: Node,
1877    src: &[u8],
1878    class_type_params: &HashSet<String>,
1879    type_facts: &FactTable,
1880) -> FactTable {
1881    let mut type_params = class_type_params.clone();
1882    type_params.extend(type_parameter_names(node, src));
1883    let mut table = FactTable::new();
1884    // `var x = Q.M(...)` is settled in a SECOND pass over the
1885    // collected declarations rather than during the walk: `Q`'s own fact has to
1886    // be FINAL before it can be read, and the table is flat, so a sibling block
1887    // declaring `Q` differently cancels it to no fact at all.
1888    let mut deferred: Vec<DeferredCall> = Vec::new();
1889    // `foreach (var item in collection)` needs a second pass too:
1890    // the collection may be a local declared anywhere in this same flat
1891    // table, including one declared AFTER this foreach in source order.
1892    let mut deferred_foreach: Vec<DeferredForeach> = Vec::new();
1893    visit_member_facts(
1894        node,
1895        src,
1896        &type_params,
1897        &mut table,
1898        &mut deferred,
1899        &mut deferred_foreach,
1900    );
1901    if !deferred.is_empty() {
1902        let pending: HashSet<&str> = deferred.iter().map(|d| d.name.as_str()).collect();
1903        for d in &deferred {
1904            // A qualifier that is itself one of these locals is refused rather
1905            // than read from a half-settled table: one hop, never a chain. The
1906            // refusal still stores the name as TAKEN, which is what keeps it
1907            // from falling through to a same-named field of a different type.
1908            let owner = if pending.contains(d.qualifier.as_str()) {
1909                None
1910            } else {
1911                qualifier_type_name(&table, type_facts, &d.qualifier)
1912            };
1913            let fact = owner.map(|type_name| Fact {
1914                type_name,
1915                args: None,
1916                call: Some(d.member.clone()),
1917            });
1918            add_fact(&mut table, Some(d.name.clone()), fact);
1919        }
1920    }
1921    for d in &deferred_foreach {
1922        // Unlike the call-owner qualifier above, no explicit
1923        // "one hop, never a chain" refusal is needed for the collection: the
1924        // derived fact never carries a type argument (see
1925        // `collection_element_fact`), so reading a SIBLING foreach variable
1926        // that has not settled yet -- or that settled to no fact at all --
1927        // both read as "no single type argument", the same refusal an
1928        // ordinary unresolvable collection gets. Order among these entries
1929        // therefore cannot change the answer.
1930        let fact = collection_element_fact(&table, type_facts, &d.collection);
1931        add_fact(&mut table, Some(d.name.clone()), fact);
1932    }
1933    table
1934}
1935
1936// One `var x = Q.M(...)` local awaiting the second pass: the local's name, and
1937// the two halves of the callee its type depends on.
1938struct DeferredCall {
1939    name: String,
1940    qualifier: String,
1941    member: String,
1942}
1943
1944// One `foreach (var item in collection)` local awaiting the
1945// second pass: the loop variable's name, and the bare identifier of the
1946// collection its element type depends on.
1947struct DeferredForeach {
1948    name: String,
1949    collection: String,
1950}
1951
1952fn visit_member_facts(
1953    n: Node,
1954    src: &[u8],
1955    type_params: &HashSet<String>,
1956    table: &mut FactTable,
1957    deferred: &mut Vec<DeferredCall>,
1958    deferred_foreach: &mut Vec<DeferredForeach>,
1959) {
1960    if n.kind() == "parameter" {
1961        add_fact(
1962            table,
1963            n.child_by_field_name("name").map(|x| text(x, src)),
1964            type_fact(n.child_by_field_name("type"), src, type_params),
1965        );
1966    } else if n.kind() == "variable_declaration" {
1967        let type_node = n.child_by_field_name("type");
1968        let is_var = type_node.map(|t| t.kind()) == Some("implicit_type");
1969        let declared = if is_var {
1970            None
1971        } else {
1972            type_fact(type_node, src, type_params)
1973        };
1974        for decl in named_children(n) {
1975            if decl.kind() != "variable_declarator" {
1976                continue;
1977            }
1978            let name = decl.child_by_field_name("name").map(|x| text(x, src));
1979            let fact = if is_var {
1980                new_expression_fact(decl, src, type_params)
1981            } else {
1982                declared.clone()
1983            };
1984            let call = match (is_var && fact.is_none(), name.as_deref()) {
1985                (true, Some(n)) if !n.is_empty() => invocation_call(decl, src),
1986                _ => None,
1987            };
1988            match call {
1989                Some((qualifier, member)) => deferred.push(DeferredCall {
1990                    name: name.unwrap_or_default(),
1991                    qualifier,
1992                    member,
1993                }),
1994                None => add_fact(table, name, fact),
1995            }
1996        }
1997    } else if n.kind() == "foreach_statement" {
1998        // foreach_statement carries its own `type`/`left` fields,
1999        // not a variable_declaration node, so it needs its own rule: an
2000        // explicitly typed loop variable is answered by that type node
2001        // exactly like any other declaration; a `var` loop variable is
2002        // answered by the COLLECTION's own fact, when that fact carries
2003        // exactly one top-level type argument (settled below, second pass);
2004        // anything else stays taken-but-unknown. A destructuring
2005        // `foreach (var (a, b) in ...)` has no single name to record and is
2006        // left alone entirely -- neither a fact nor a taken slot.
2007        let left = n.child_by_field_name("left");
2008        let name = left
2009            .filter(|l| l.kind() == "identifier")
2010            .map(|l| text(l, src));
2011        if let Some(name) = name {
2012            let type_node = n.child_by_field_name("type");
2013            let is_var = type_node
2014                .map(|t| t.kind() == "implicit_type")
2015                .unwrap_or(true);
2016            if !is_var {
2017                add_fact(table, Some(name), type_fact(type_node, src, type_params));
2018            } else {
2019                let right = n.child_by_field_name("right");
2020                match right.filter(|r| r.kind() == "identifier") {
2021                    Some(r) => deferred_foreach.push(DeferredForeach {
2022                        name,
2023                        collection: text(r, src),
2024                    }),
2025                    None => add_fact(table, Some(name), None),
2026                }
2027            }
2028        }
2029    }
2030    for c in named_children(n) {
2031        visit_member_facts(c, src, type_params, table, deferred, deferred_foreach);
2032    }
2033}
2034
2035// The (qualifier, member) halves of a `var x = Q.M(...)`
2036// initializer, or `None` for every other shape. The qualifier must be BARE and
2037// non-generic for the same reason a receiver fact's is: a dotted or computed
2038// qualifier is not a name the ladder can put a type behind. A bare call
2039// (`var x = M()`) has no qualifier at all and is deliberately not covered.
2040fn invocation_call(declarator: Node, src: &[u8]) -> Option<(String, String)> {
2041    let init = named_children(declarator)
2042        .into_iter()
2043        .find(|c| c.kind() == "invocation_expression")?;
2044    let function = init.child_by_field_name("function")?;
2045    if function.kind() != "member_access_expression" {
2046        return None;
2047    }
2048    let (qualifier, generic) =
2049        member_qualifier_info(function.child_by_field_name("expression"), src)?;
2050    if generic || qualifier.contains('.') {
2051        return None;
2052    }
2053    let name_node = function.child_by_field_name("name")?;
2054    let member = if name_node.kind() == "generic_name" {
2055        named_children(name_node)
2056            .into_iter()
2057            .find(|c| c.kind() == "identifier")
2058            .map(|id| text(id, src))?
2059    } else {
2060        text(name_node, src)
2061    };
2062    if member.is_empty() {
2063        return None;
2064    }
2065    Some((qualifier, member))
2066}
2067
2068// The type NAME a bare qualifier stands for, as far as the file can vouch: the
2069// fact's own type when one vouches for the name; `None` when the name is taken
2070// but nothing vouches for it, or when what vouches is itself a call
2071// fact (one hop, never a chain); and the text itself when no declaration in
2072// scope claims the name at all -- an unclaimed bare qualifier is a type name,
2073// which is the static-call shape.
2074fn qualifier_type_name(locals: &FactTable, type_facts: &FactTable, name: &str) -> Option<String> {
2075    match locals.get(name).or_else(|| type_facts.get(name)) {
2076        None => Some(name.to_string()),
2077        Some(Some(fact)) if fact.call.is_none() => Some(fact.type_name.clone()),
2078        Some(_) => None,
2079    }
2080}
2081
2082// The collection's OWN fact, read the same two tables
2083// `qualifier_type_name` reads, but never falling back to the bare name as a
2084// type: an unclaimed identifier is nobody's collection, not a static type to
2085// guess with. A single top-level type argument is the only shape that
2086// vouches for an element type; a wildcard (the enclosing declaration's own
2087// type parameter) vouches for nothing here either, because nothing at this
2088// site knows what it is bound to.
2089fn collection_element_fact(locals: &FactTable, type_facts: &FactTable, name: &str) -> Option<Fact> {
2090    match locals.get(name).or_else(|| type_facts.get(name)) {
2091        Some(Some(fact)) => match fact.args.as_deref() {
2092            Some([arg]) if arg != "*" => Some(Fact {
2093                type_name: arg.clone(),
2094                args: None,
2095                call: None,
2096            }),
2097            _ => None,
2098        },
2099        _ => None,
2100    }
2101}
2102
2103// Declarations that own a body a local can be declared in. A local function
2104// is deliberately NOT here: its locals belong to the enclosing member's flat
2105// table (collect_member_facts already walked into it), and giving it its own
2106// scope would hide the enclosing method's locals from it.
2107const MEMBER_SCOPE_NODES: &[&str] = &[
2108    "method_declaration",
2109    "constructor_declaration",
2110    "destructor_declaration",
2111    "operator_declaration",
2112    "conversion_operator_declaration",
2113    "property_declaration",
2114    "indexer_declaration",
2115];
2116
2117// The enclosing type's field/primary-ctor table plus, inside a member
2118// declaration, that member's own local/param table layered over it.
2119struct Scope<'a> {
2120    /// Shared by reference with every member scope opened under the same
2121    /// type declaration.
2122    type_facts: Rc<FactTable>,
2123    /// The enclosing type declaration's own type-parameter names, shared the
2124    /// same way. A member scope unions its own on top when it builds its
2125    /// member table (see `collect_member_facts`).
2126    type_params: Rc<HashSet<String>>,
2127    /// `None` at type-body level: a type body is not a member body.
2128    node: Option<Node<'a>>,
2129    /// Built on FIRST USE, not on scope entry: most member declarations in a
2130    /// real file contain no bare-identifier member access at all, and the
2131    /// scan is a second traversal of the member's subtree. Laziness is
2132    /// invisible in the output -- the same table, only built on demand.
2133    member_facts: OnceCell<Option<FactTable>>,
2134}
2135
2136impl<'a> Scope<'a> {
2137    fn root() -> Self {
2138        Scope {
2139            type_facts: Rc::new(FactTable::new()),
2140            type_params: Rc::new(HashSet::new()),
2141            node: None,
2142            member_facts: OnceCell::new(),
2143        }
2144    }
2145
2146    fn for_type(node: Node<'a>, src: &[u8]) -> Self {
2147        let type_params = type_parameter_names(node, src);
2148        Scope {
2149            type_facts: Rc::new(collect_type_facts(node, src, &type_params)),
2150            type_params: Rc::new(type_params),
2151            node: None,
2152            member_facts: OnceCell::new(),
2153        }
2154    }
2155
2156    fn for_member(&self, node: Node<'a>) -> Self {
2157        Scope {
2158            type_facts: Rc::clone(&self.type_facts),
2159            type_params: Rc::clone(&self.type_params),
2160            node: Some(node),
2161            member_facts: OnceCell::new(),
2162        }
2163    }
2164
2165    // A member-table entry answers even when its
2166    // value is `None` -- a local whose type nothing vouches for must NOT
2167    // fall through to a same-named field of a different type.
2168    fn receiver_fact_for(&self, name: &str, src: &[u8]) -> Option<Fact> {
2169        let locals = self.member_facts.get_or_init(|| {
2170            self.node
2171                .map(|n| collect_member_facts(n, src, &self.type_params, &self.type_facts))
2172        });
2173        if let Some(table) = locals {
2174            if let Some(found) = table.get(name) {
2175                return found.clone();
2176            }
2177        }
2178        self.type_facts.get(name).cloned().flatten()
2179    }
2180}
2181
2182// walk_list mutates its local `ns` mid-iteration for a FILE-SCOPED
2183// namespace (`namespace X;`): tree-sitter parses its declared name as the
2184// node's only child, and every member that follows is a SIBLING of that
2185// node under the same parent, not a descendant of it. So the namespace
2186// switch happens while iterating a sibling list, updating ns for the
2187// remaining siblings in that same list -- a per-node match arm can't
2188// express that, hence walk_list and walk staying separate functions.
2189fn walk_list<'a>(
2190    nodes: Vec<Node<'a>>,
2191    mut ns: String,
2192    type_stack: &[String],
2193    src: &[u8],
2194    out: &mut Extraction,
2195    scope: &Scope<'a>,
2196) {
2197    for node in nodes {
2198        if node.kind() == "file_scoped_namespace_declaration" {
2199            let name = node
2200                .child_by_field_name("name")
2201                .map(|n| text(n, src))
2202                .unwrap_or_default();
2203            ns = if ns.is_empty() {
2204                name
2205            } else {
2206                format!("{ns}.{name}")
2207            };
2208            continue;
2209        }
2210        walk(node, &ns, type_stack, src, out, scope);
2211    }
2212}
2213
2214// type_stack (see type_id) also doubles as the "am I inside a type" signal
2215// for nothing else -- ref extraction runs at every depth regardless of
2216// nesting.
2217fn walk<'a>(
2218    node: Node<'a>,
2219    ns: &str,
2220    type_stack: &[String],
2221    src: &[u8],
2222    out: &mut Extraction,
2223    scope: &Scope<'a>,
2224) {
2225    // Entering a member declaration installs a fresh (lazily built)
2226    // local/param table over the enclosing type's field table, for this
2227    // subtree only.
2228    let member_scope;
2229    let scope: &Scope<'a> = if MEMBER_SCOPE_NODES.contains(&node.kind()) {
2230        member_scope = scope.for_member(node);
2231        &member_scope
2232    } else {
2233        scope
2234    };
2235    match node.kind() {
2236        "namespace_declaration" => {
2237            let name = node
2238                .child_by_field_name("name")
2239                .map(|n| text(n, src))
2240                .unwrap_or_default();
2241            let new_ns = if ns.is_empty() {
2242                name
2243            } else {
2244                format!("{ns}.{name}")
2245            };
2246            walk_list(named_children(node), new_ns, type_stack, src, out, scope);
2247        }
2248        "using_directive" => {
2249            record_using(node, src, &mut out.usings, &mut out.refs);
2250        }
2251        "class_declaration"
2252        | "interface_declaration"
2253        | "struct_declaration"
2254        | "record_declaration" => {
2255            let kind = type_kind_label(node.kind()).expect("matched TYPE_KINDS arm");
2256            // Computed before record_type_def (Scope::for_type
2257            // below recomputes its own copy for the type-facts table; cheap
2258            // and kept separate rather than threading one instance through
2259            // both call sites).
2260            let type_params = type_parameter_names(node, src);
2261            record_type_def(
2262                node,
2263                ns,
2264                kind,
2265                type_stack,
2266                src,
2267                &mut out.defs,
2268                &mut out.names,
2269                &type_params,
2270            );
2271            record_base_list(node, ns, type_stack, src, &mut out.refs);
2272            let name = declared_name(node, src);
2273            let new_stack: Vec<String> = if name.is_empty() {
2274                type_stack.to_vec()
2275            } else {
2276                let mut s = type_stack.to_vec();
2277                s.push(name);
2278                s
2279            };
2280            // A type declaration opens a new field/primary-ctor table and
2281            // closes any enclosing member scope (`node: None` -- a type body
2282            // is not a member body).
2283            let type_scope = Scope::for_type(node, src);
2284            walk_list(
2285                named_children(node),
2286                ns.to_string(),
2287                &new_stack,
2288                src,
2289                out,
2290                &type_scope,
2291            );
2292        }
2293        "enum_declaration" => {
2294            record_type_def(
2295                node,
2296                ns,
2297                "enum",
2298                type_stack,
2299                src,
2300                &mut out.defs,
2301                &mut out.names,
2302                &HashSet::new(),
2303            );
2304            record_enum_members(node, ns, type_stack, src, &mut out.defs);
2305            // No recursion into the enum body: enum member initializer
2306            // expressions are not walked.
2307        }
2308        "delegate_declaration" => {
2309            record_type_def(
2310                node,
2311                ns,
2312                "delegate",
2313                type_stack,
2314                src,
2315                &mut out.defs,
2316                &mut out.names,
2317                &HashSet::new(),
2318            );
2319            record_single_type(
2320                node.child_by_field_name("type"),
2321                "uses-type",
2322                ns,
2323                type_stack,
2324                src,
2325                &mut out.refs,
2326            );
2327            walk_list(
2328                named_children(node),
2329                ns.to_string(),
2330                type_stack,
2331                src,
2332                out,
2333                scope,
2334            );
2335        }
2336        "field_declaration" => {
2337            let vd = named_children(node)
2338                .into_iter()
2339                .find(|c| c.kind() == "variable_declaration");
2340            record_single_type(
2341                vd.and_then(|v| v.child_by_field_name("type")),
2342                "uses-type",
2343                ns,
2344                type_stack,
2345                src,
2346                &mut out.refs,
2347            );
2348            walk_list(
2349                named_children(node),
2350                ns.to_string(),
2351                type_stack,
2352                src,
2353                out,
2354                scope,
2355            );
2356        }
2357        "property_declaration" => {
2358            record_single_type(
2359                node.child_by_field_name("type"),
2360                "uses-type",
2361                ns,
2362                type_stack,
2363                src,
2364                &mut out.refs,
2365            );
2366            walk_list(
2367                named_children(node),
2368                ns.to_string(),
2369                type_stack,
2370                src,
2371                out,
2372                scope,
2373            );
2374        }
2375        "parameter" => {
2376            record_single_type(
2377                node.child_by_field_name("type"),
2378                "uses-type",
2379                ns,
2380                type_stack,
2381                src,
2382                &mut out.refs,
2383            );
2384            walk_list(
2385                named_children(node),
2386                ns.to_string(),
2387                type_stack,
2388                src,
2389                out,
2390                scope,
2391            );
2392        }
2393        // One 'ctor-param' fact per constructor parameter,
2394        // ALONGSIDE (never instead of) the plain 'uses-type' ref the
2395        // "parameter" arm above still emits as walk_list recurses into this
2396        // node's own parameter_list below. Uses the same type_fact helper
2397        // stage-2 field/local receiver facts use, so a parameter's generic
2398        // arguments survive as descriptors instead of being stripped to a
2399        // bare name the way the general 'uses-type' ladder strips them.
2400        // scope.type_params is already the enclosing TYPE's own parameters
2401        // here (MEMBER_SCOPE_NODES swapped in a member scope above with the
2402        // SAME type_params -- a C# constructor cannot declare type
2403        // parameters of its own).
2404        "constructor_declaration" => {
2405            if let Some(params) = node.child_by_field_name("parameters") {
2406                for p in named_children(params) {
2407                    if p.kind() != "parameter" {
2408                        continue;
2409                    }
2410                    let type_node = p.child_by_field_name("type");
2411                    if let Some(fact) = type_fact(type_node, src, &scope.type_params) {
2412                        let line = type_node.map(|t| t.start_position().row + 1).unwrap_or(0);
2413                        push_ctor_param_ref(
2414                            &mut out.refs,
2415                            fact.type_name,
2416                            line,
2417                            ns.to_string(),
2418                            fact.args,
2419                            type_stack,
2420                        );
2421                    }
2422                }
2423            }
2424            walk_list(
2425                named_children(node),
2426                ns.to_string(),
2427                type_stack,
2428                src,
2429                out,
2430                scope,
2431            );
2432        }
2433        // `Method(out SomeEnum x)` / `obj is SomeEnum x` inline-declaration
2434        // sites -- a `type` + `name` pair, same shape as `parameter`, just in
2435        // expression position instead of a parameter list. Same
2436        // record_single_type pipeline, so it goes through the normal ladder
2437        // including ambiguous marking, exactly like an ordinary type usage.
2438        "declaration_expression" => {
2439            record_single_type(
2440                node.child_by_field_name("type"),
2441                "uses-type",
2442                ns,
2443                type_stack,
2444                src,
2445                &mut out.refs,
2446            );
2447            walk_list(
2448                named_children(node),
2449                ns.to_string(),
2450                type_stack,
2451                src,
2452                out,
2453                scope,
2454            );
2455        }
2456        "method_declaration" => {
2457            record_single_type(
2458                node.child_by_field_name("returns"),
2459                "uses-type",
2460                ns,
2461                type_stack,
2462                src,
2463                &mut out.refs,
2464            );
2465            walk_list(
2466                named_children(node),
2467                ns.to_string(),
2468                type_stack,
2469                src,
2470                out,
2471                scope,
2472            );
2473        }
2474        "object_creation_expression" => {
2475            record_single_type(
2476                node.child_by_field_name("type"),
2477                "uses-type",
2478                ns,
2479                type_stack,
2480                src,
2481                &mut out.refs,
2482            );
2483            walk_list(
2484                named_children(node),
2485                ns.to_string(),
2486                type_stack,
2487                src,
2488                out,
2489                scope,
2490            );
2491        }
2492        "typeof_expression" => {
2493            record_single_type(
2494                node.child_by_field_name("type"),
2495                "uses-type",
2496                ns,
2497                type_stack,
2498                src,
2499                &mut out.refs,
2500            );
2501            walk_list(
2502                named_children(node),
2503                ns.to_string(),
2504                type_stack,
2505                src,
2506                out,
2507                scope,
2508            );
2509        }
2510        // Covers every position a member access can appear in -- cast-to-int
2511        // values over a work-type enum, argument lists, switch/pattern arms,
2512        // initializers -- because none of those container node types are
2513        // special-cased above, so they all reach here via the default walk.
2514        "member_access_expression" => {
2515            let expr_field = node.child_by_field_name("expression");
2516            // The member itself can be a generic_name too ("Foo.Bar<T>(...)"):
2517            // normalize to the bare method name so the resolver's method-list
2518            // membership check sees the name the def actually recorded.
2519            let member = node.child_by_field_name("name").map(|n| {
2520                if n.kind() == "generic_name" {
2521                    named_children(n)
2522                        .into_iter()
2523                        .find(|c| c.kind() == "identifier")
2524                        .map(|id| text(id, src))
2525                        .unwrap_or_default()
2526                } else {
2527                    text(n, src)
2528                }
2529            });
2530            let normal_qualifier = member_qualifier_info(expr_field, src);
2531            if let (Some((qt, generic)), Some(m)) = (&normal_qualifier, &member) {
2532                if !m.is_empty() {
2533                    // A receiver fact is asked for ONLY for a bare,
2534                    // non-generic qualifier: a dotted qualifier is a
2535                    // flattened chain window (whose head's fact it must never
2536                    // inherit) or a namespace path, and a type-argument list
2537                    // is syntax no local, parameter, or field can carry.
2538                    let dot_at = if *generic { None } else { qt.find('.') };
2539                    let bare = dot_at.is_none() && !*generic;
2540                    let receiver = if bare {
2541                        scope.receiver_fact_for(qt, src)
2542                    } else {
2543                        None
2544                    };
2545                    // The head of a TWO-segment chain, and only when
2546                    // the scope vouches for its type: "a.Settings" asks what
2547                    // `a` is, while "x.y.Settings" and a namespace path ask
2548                    // nothing, because a head this file cannot type is a head
2549                    // no property lookup can start from.
2550                    let property_owner = dot_at
2551                        .filter(|d| !qt[d + 1..].contains('.'))
2552                        .and_then(|d| scope.receiver_fact_for(&qt[..d], src))
2553                        .filter(|fact| fact.call.is_none())
2554                        .map(|fact| fact.type_name);
2555                    push_member_ref(
2556                        &mut out.refs,
2557                        qt,
2558                        m.clone(),
2559                        node.start_position().row + 1,
2560                        ns.to_string(),
2561                        *generic,
2562                        receiver,
2563                        // Asked of THIS node, so a chain window answers for its
2564                        // own call and never for the one wrapping it.
2565                        invocation_arg_count(node),
2566                        type_stack,
2567                        property_owner,
2568                    );
2569                }
2570            }
2571            walk_list(
2572                named_children(node),
2573                ns.to_string(),
2574                type_stack,
2575                src,
2576                out,
2577                scope,
2578            );
2579        }
2580        "type_argument_list" => {
2581            for arg in named_children(node) {
2582                record_single_type(Some(arg), "uses-type", ns, type_stack, src, &mut out.refs);
2583            }
2584            walk_list(
2585                named_children(node),
2586                ns.to_string(),
2587                type_stack,
2588                src,
2589                out,
2590                scope,
2591            );
2592        }
2593        _ => {
2594            walk_list(
2595                named_children(node),
2596                ns.to_string(),
2597                type_stack,
2598                src,
2599                out,
2600                scope,
2601            );
2602        }
2603    }
2604}
2605
2606// ---------------------------------------------------------------------------
2607// Entry points.
2608// ---------------------------------------------------------------------------
2609
2610/// Extract both outputs from one parse of C# `source`: the file's purpose
2611/// signature and its graph fragment (defs, usings, refs, names).
2612pub fn extract(source: &str) -> Extraction {
2613    let mut parser = new_parser();
2614    let units = crate::parse::utf16_units(source);
2615    let utf16 = crate::parse::utf16_bytes(&units);
2616    let tree = parser
2617        .parse_utf16_le(&units, None)
2618        .expect("parse returned no tree");
2619    let root = tree.root_node();
2620    let src = &utf16[..];
2621
2622    let purpose_parts: Vec<SegmentParts> = namespace_level_types(root)
2623        .into_iter()
2624        .filter_map(|n| type_segment_parts(n, src))
2625        .collect();
2626
2627    let mut out = Extraction {
2628        purpose: None,
2629        defs: Vec::new(),
2630        usings: Vec::new(),
2631        refs: Vec::new(),
2632        names: Vec::new(),
2633    };
2634    walk_list(
2635        named_children(root),
2636        String::new(),
2637        &[],
2638        src,
2639        &mut out,
2640        &Scope::root(),
2641    );
2642
2643    out.purpose = format_segments(purpose_parts);
2644
2645    out
2646}
2647
2648// ---------------------------------------------------------------------------
2649// `extract-dump` subcommand -- canonical JSON for one file.
2650// ---------------------------------------------------------------------------
2651
2652/// Extracts the C# file at `path` and prints the extraction as JSON.
2653pub fn run_extract_dump(path: &str) {
2654    let source = fs::read_to_string(path).unwrap_or_else(|err| {
2655        eprintln!("failed to read {path}: {err}");
2656        process::exit(1);
2657    });
2658    // The dump path dispatches on extension: a C# file yields a fragment read
2659    // as `{purpose, defs, usings, refs, names}`, while a TS/JS file yields a
2660    // `ts: 1` REFERENCE fragment carrying `defs` and `refs` but no `usings`
2661    // and no `names` -- and serialization drops an absent value's key, so the
2662    // TS dump is a three-key object. The C# dump below keeps all five keys.
2663    if let Some(grammar) = crate::parse::ts_grammar_for(path) {
2664        // Compute `root = dirname(path)`, `rel = basename(path)` and go
2665        // through `extract_ts_file` -- the SAME hybrid-aware worker path
2666        // `devscout map` uses, not the pure extractor -- so the dump exercises
2667        // the leading-comment prefix, off one single parse.
2668        let file_path = Path::new(path);
2669        let root = file_path
2670            .parent()
2671            .filter(|p| !p.as_os_str().is_empty())
2672            .unwrap_or_else(|| Path::new("."));
2673        let rel = file_path
2674            .file_name()
2675            .and_then(|n| n.to_str())
2676            .unwrap_or(path);
2677        match extract_ts_file(root, rel, &source, grammar) {
2678            Some(ts) => println!("{}", ts_extraction_to_json(&ts.purpose, &ts.fragment)),
2679            // A parse failure leaves the file out of both outputs, so its
2680            // dump is the empty fragment under a null purpose.
2681            None => println!("{}", ts_extraction_to_json(&None, &TsFragment::default())),
2682        }
2683        return;
2684    }
2685    let result = extract(&source);
2686    println!("{}", extraction_to_json(&result));
2687}
2688
2689// Hand-rolled JSON value + pretty printer matching
2690// `JSON.stringify(value, null, 2)` byte-for-byte (2-space indent, no
2691// trailing commas, empty arrays/objects collapse to `[]`/`{}` on one line)
2692// -- no serde dependency, per the same "no new dependency" rule
2693// parse.rs's spans_json already follows. Generalized here (unlike
2694// spans_json's fixed six-field record) because extraction records are
2695// heterogeneous (optional fields, nested arrays of objects).
2696enum Json {
2697    Null,
2698    Bool(bool),
2699    Num(usize),
2700    /// A SIGNED number, for the one field that can be negative: an extension
2701    /// entry's `arityMax`, where -1 is the unbounded-`params` sentinel,
2702    /// written as a plain JSON number.
2703    Int(i64),
2704    Str(String),
2705    Arr(Vec<Json>),
2706    Obj(Vec<(&'static str, Json)>),
2707    /// Same encoding as `Obj`, for the one record whose keys are DATA rather
2708    /// than a fixed schema: a def's `methodReturns`. Insertion order is
2709    /// significant (first-declaration source order), so this is a Vec of
2710    /// pairs, never a sorted map.
2711    Map(Vec<(String, Json)>),
2712}
2713
2714impl Json {
2715    fn to_pretty_string(&self) -> String {
2716        let mut out = String::new();
2717        self.write(&mut out, 0);
2718        out
2719    }
2720
2721    fn write(&self, out: &mut String, indent: usize) {
2722        match self {
2723            Json::Null => out.push_str("null"),
2724            Json::Bool(b) => out.push_str(if *b { "true" } else { "false" }),
2725            Json::Num(n) => out.push_str(&n.to_string()),
2726            Json::Int(n) => out.push_str(&n.to_string()),
2727            Json::Str(s) => out.push_str(&json_string(s)),
2728            Json::Arr(items) => {
2729                if items.is_empty() {
2730                    out.push_str("[]");
2731                    return;
2732                }
2733                out.push_str("[\n");
2734                let pad = "  ".repeat(indent + 1);
2735                let last = items.len() - 1;
2736                for (i, item) in items.iter().enumerate() {
2737                    out.push_str(&pad);
2738                    item.write(out, indent + 1);
2739                    if i != last {
2740                        out.push(',');
2741                    }
2742                    out.push('\n');
2743                }
2744                out.push_str(&"  ".repeat(indent));
2745                out.push(']');
2746            }
2747            Json::Obj(fields) => {
2748                write_object(out, indent, fields.iter().map(|(k, v)| (*k, v)));
2749            }
2750            Json::Map(entries) => {
2751                write_object(out, indent, entries.iter().map(|(k, v)| (k.as_str(), v)));
2752            }
2753        }
2754    }
2755}
2756
2757fn write_object<'a>(
2758    out: &mut String,
2759    indent: usize,
2760    fields: impl ExactSizeIterator<Item = (&'a str, &'a Json)>,
2761) {
2762    if fields.len() == 0 {
2763        out.push_str("{}");
2764        return;
2765    }
2766    out.push_str("{\n");
2767    let pad = "  ".repeat(indent + 1);
2768    let last = fields.len() - 1;
2769    for (i, (k, v)) in fields.enumerate() {
2770        out.push_str(&pad);
2771        out.push_str(&json_string(k));
2772        out.push_str(": ");
2773        v.write(out, indent + 1);
2774        if i != last {
2775            out.push(',');
2776        }
2777        out.push('\n');
2778    }
2779    out.push_str(&"  ".repeat(indent));
2780    out.push('}');
2781}
2782
2783fn json_string(s: &str) -> String {
2784    let mut out = String::with_capacity(s.len() + 2);
2785    out.push('"');
2786    for ch in s.chars() {
2787        match ch {
2788            '"' => out.push_str("\\\""),
2789            '\\' => out.push_str("\\\\"),
2790            '\n' => out.push_str("\\n"),
2791            '\r' => out.push_str("\\r"),
2792            '\t' => out.push_str("\\t"),
2793            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
2794            c => out.push(c),
2795        }
2796    }
2797    out.push('"');
2798    out
2799}
2800
2801fn def_to_json(d: &DefRecord) -> Json {
2802    let mut fields: Vec<(&'static str, Json)> = vec![
2803        ("id", Json::Str(d.id.clone())),
2804        ("name", Json::Str(d.name.clone())),
2805        ("namespace", Json::Str(d.namespace.clone())),
2806        ("kind", Json::Str(d.kind.clone())),
2807        ("line", Json::Num(d.line)),
2808        (
2809            "methods",
2810            Json::Arr(d.methods.iter().map(|m| Json::Str(m.clone())).collect()),
2811        ),
2812    ];
2813    // Appended last, in declaration order, each only when non-empty.
2814    if !d.properties.is_empty() {
2815        fields.push((
2816            "properties",
2817            Json::Arr(d.properties.iter().map(|p| Json::Str(p.clone())).collect()),
2818        ));
2819    }
2820    if !d.fields.is_empty() {
2821        fields.push((
2822            "fields",
2823            Json::Arr(d.fields.iter().map(|f| Json::Str(f.clone())).collect()),
2824        ));
2825    }
2826    if !d.method_returns.is_empty() {
2827        fields.push((
2828            "methodReturns",
2829            Json::Map(
2830                d.method_returns
2831                    .iter()
2832                    .map(|(k, v)| (k.clone(), Json::Str(v.clone())))
2833                    .collect(),
2834            ),
2835        ));
2836    }
2837    // Appended after the properties/fields/methodReturns trio, entry keys in
2838    // the serialized order (name, thisType, arityMin, arityMax, thisArgs),
2839    // omitted when empty. `thisArgs` is present only on a generic
2840    // this-parameter.
2841    if !d.extension_methods.is_empty() {
2842        fields.push((
2843            "extensionMethods",
2844            Json::Arr(
2845                d.extension_methods
2846                    .iter()
2847                    .map(|e| {
2848                        let mut kv: Vec<(&'static str, Json)> = vec![
2849                            ("name", Json::Str(e.name.clone())),
2850                            ("thisType", Json::Str(e.this_type.clone())),
2851                            ("arityMin", Json::Num(e.arity_min)),
2852                            ("arityMax", Json::Int(e.arity_max)),
2853                        ];
2854                        if let Some(args) = &e.this_args {
2855                            kv.push((
2856                                "thisArgs",
2857                                Json::Arr(args.iter().map(|a| Json::Str(a.clone())).collect()),
2858                            ));
2859                        }
2860                        Json::Obj(kv)
2861                    })
2862                    .collect(),
2863            ),
2864        ));
2865    }
2866    // Appended after extensionMethods.
2867    if !d.bases.is_empty() {
2868        fields.push((
2869            "bases",
2870            Json::Arr(d.bases.iter().map(|b| Json::Str(b.clone())).collect()),
2871        ));
2872    }
2873    // Appended after bases, before testMethods, each only when non-empty.
2874    if !d.type_params.is_empty() {
2875        fields.push((
2876            "typeParams",
2877            Json::Arr(d.type_params.iter().map(|t| Json::Str(t.clone())).collect()),
2878        ));
2879    }
2880    if !d.base_generic_args.is_empty() {
2881        fields.push((
2882            "baseGenericArgs",
2883            Json::Map(
2884                d.base_generic_args
2885                    .iter()
2886                    .map(|(k, v)| {
2887                        (
2888                            k.clone(),
2889                            Json::Arr(v.iter().map(|a| Json::Str(a.clone())).collect()),
2890                        )
2891                    })
2892                    .collect(),
2893            ),
2894        ));
2895    }
2896    // Appended after baseGenericArgs.
2897    if !d.test_methods.is_empty() {
2898        fields.push((
2899            "testMethods",
2900            Json::Arr(
2901                d.test_methods
2902                    .iter()
2903                    .map(|t| Json::Str(t.clone()))
2904                    .collect(),
2905            ),
2906        ));
2907    }
2908    // Appended LAST, after testMethods, entry keys in source order.
2909    if !d.property_types.is_empty() {
2910        fields.push((
2911            "propertyTypes",
2912            Json::Map(
2913                d.property_types
2914                    .iter()
2915                    .map(|(name, fact)| (name.clone(), fact_to_json(fact)))
2916                    .collect(),
2917            ),
2918        ));
2919    }
2920    Json::Obj(fields)
2921}
2922
2923// One declared type fact: `{type}`, or `{type, args}` when the declared type
2924// carried a top-level type-argument list. Field order is significant, and
2925// `args` is omitted when absent.
2926fn fact_to_json(fact: &Fact) -> Json {
2927    let mut fields: Vec<(&'static str, Json)> = vec![("type", Json::Str(fact.type_name.clone()))];
2928    if let Some(args) = &fact.args {
2929        fields.push((
2930            "args",
2931            Json::Arr(args.iter().map(|a| Json::Str(a.clone())).collect()),
2932        ));
2933    }
2934    Json::Obj(fields)
2935}
2936
2937fn using_to_json(u: &UsingRecord) -> Json {
2938    match u {
2939        UsingRecord::Alias {
2940            alias,
2941            target,
2942            global,
2943        } => Json::Obj(vec![
2944            ("alias", Json::Str(alias.clone())),
2945            ("target", Json::Str(target.clone())),
2946            ("global", Json::Bool(*global)),
2947        ]),
2948        UsingRecord::Plain { text, global } => Json::Obj(vec![
2949            ("text", Json::Str(text.clone())),
2950            ("global", Json::Bool(*global)),
2951        ]),
2952    }
2953}
2954
2955fn ref_to_json(r: &RefRecord) -> Json {
2956    let mut fields: Vec<(&'static str, Json)> = vec![
2957        ("kind", Json::Str(r.kind.clone())),
2958        ("name", Json::Str(r.name.clone())),
2959    ];
2960    if let Some(q) = &r.qualified {
2961        fields.push(("qualified", Json::Str(q.clone())));
2962    }
2963    if let Some(m) = &r.member {
2964        fields.push(("member", Json::Str(m.clone())));
2965    }
2966    fields.push(("line", Json::Num(r.line)));
2967    fields.push((
2968        "namespace",
2969        match &r.namespace {
2970            Some(ns) => Json::Str(ns.clone()),
2971            None => Json::Null,
2972        },
2973    ));
2974    if let Some(arity) = r.type_arg_count {
2975        fields.push(("typeArgCount", Json::Num(arity)));
2976    }
2977    // Both appended last, in that order, and only when set.
2978    if r.generic {
2979        fields.push(("generic", Json::Bool(true)));
2980    }
2981    if let Some(rt) = &r.receiver_type {
2982        fields.push(("receiverType", Json::Str(rt.clone())));
2983    }
2984    // Appended after receiverType, and only when the access was a callee. The
2985    // test is presence, not truthiness: argCount 0 is a real value.
2986    if let Some(ac) = r.arg_count {
2987        fields.push(("argCount", Json::Num(ac)));
2988    }
2989    // Appended LAST, after argCount, and only
2990    // when the receiver's DECLARED type was generic.
2991    if let Some(args) = &r.receiver_args {
2992        fields.push((
2993            "receiverArgs",
2994            Json::Arr(args.iter().map(|a| Json::Str(a.clone())).collect()),
2995        ));
2996    }
2997    // Appended LAST of all, after receiverArgs, and only when the ref sits
2998    // inside a type.
2999    if !r.outer_types.is_empty() {
3000        fields.push((
3001            "outerTypes",
3002            Json::Arr(r.outer_types.iter().map(|t| Json::Str(t.clone())).collect()),
3003        ));
3004    }
3005    // Appended LAST of all, after outerTypes, and only set for a 'ctor-param'
3006    // ref whose type was generic.
3007    if let Some(args) = &r.args {
3008        fields.push((
3009            "args",
3010            Json::Arr(args.iter().map(|a| Json::Str(a.clone())).collect()),
3011        ));
3012    }
3013    // Appended after args, and only for a two-segment chain whose
3014    // head the enclosing scope could type.
3015    if let Some(owner) = &r.receiver_property_owner {
3016        fields.push(("receiverPropertyOwner", Json::Str(owner.clone())));
3017    }
3018    // Appended LAST of all, and always as a pair.
3019    if let Some(owner) = &r.receiver_call_owner {
3020        fields.push(("receiverCallOwner", Json::Str(owner.clone())));
3021    }
3022    if let Some(member) = &r.receiver_call_member {
3023        fields.push(("receiverCallMember", Json::Str(member.clone())));
3024    }
3025    Json::Obj(fields)
3026}
3027
3028// Field order (`name`, `kind`, `line`, `owner`) is significant, `owner`
3029// omitted when empty.
3030fn name_to_json(n: &NameRecord) -> Json {
3031    let mut fields: Vec<(&'static str, Json)> = vec![
3032        ("name", Json::Str(n.name.clone())),
3033        ("kind", Json::Str(n.kind.clone())),
3034        ("line", Json::Num(n.line)),
3035    ];
3036    if !n.owner.is_empty() {
3037        fields.push(("owner", Json::Str(n.owner.clone())));
3038    }
3039    Json::Obj(fields)
3040}
3041
3042/// Serializes a C# extraction as JSON.
3043pub fn extraction_to_json(e: &Extraction) -> String {
3044    let purpose_json = match &e.purpose {
3045        Some(p) => Json::Str(p.clone()),
3046        None => Json::Null,
3047    };
3048    let root = Json::Obj(vec![
3049        ("purpose", purpose_json),
3050        ("defs", Json::Arr(e.defs.iter().map(def_to_json).collect())),
3051        (
3052            "usings",
3053            Json::Arr(e.usings.iter().map(using_to_json).collect()),
3054        ),
3055        ("refs", Json::Arr(e.refs.iter().map(ref_to_json).collect())),
3056        (
3057            "names",
3058            Json::Arr(e.names.iter().map(name_to_json).collect()),
3059        ),
3060    ]);
3061    root.to_pretty_string()
3062}
3063
3064// ---------------------------------------------------------------------------
3065// TS/JS purpose extraction. PURPOSES ONLY:
3066// this section never builds a def/ref/usings candidate, and nothing here is
3067// reachable from `extract()`/`walk()` above (the C# graph-fragment walk).
3068// Entry point is `extract_ts_purpose`, called from mapcmd.rs's per-file
3069// dispatch for any `.ts`/`.tsx`/`.js`/`.jsx` path (parse.rs's `ts_grammar_for`
3070// gate) and from `run_extract_dump` above for the extract-dump harness.
3071// ---------------------------------------------------------------------------
3072
3073// One exported top-level declaration's contribution to the purpose line, as
3074// a `{kind, name, bases, methods}` record. `kind` already carries the literal
3075// string `"default"` when this entry wraps a default export (the kind-word is
3076// replaced by the literal `default`), so `compose_ts_purpose` never branches
3077// on default-ness
3078// itself -- it just prints `kind` as the header word, exactly like the
3079// bucket-sort below groups on it.
3080struct TsEntry {
3081    kind: &'static str,
3082    name: String,
3083    bases: String,
3084    methods: Vec<String>,
3085}
3086
3087// A method/property `name` field is absent (or a `computed_property_name`,
3088// e.g. `[Symbol.iterator]() {}`) exactly when there is nothing to report --
3089// no semantic resolution of computed names, same zero-guessing stance as
3090// `outer_type_name` above.
3091fn ts_declared_name(node: Node, src: &[u8]) -> String {
3092    match node.child_by_field_name("name") {
3093        Some(n) if n.kind() != "computed_property_name" => text(n, src),
3094        _ => String::new(),
3095    }
3096}
3097
3098fn ts_accessibility(node: Node, src: &[u8]) -> Option<String> {
3099    named_children(node)
3100        .into_iter()
3101        .find(|c| c.kind() == "accessibility_modifier")
3102        .map(|m| text(m, src))
3103}
3104
3105// No accessibility_modifier, or an explicit public one -- an absent modifier
3106// defaults to public in TS, the INVERSE of C#'s implicit-internal-unless-
3107// public default; the two languages differ here on purpose and this follows
3108// TS's own default, not C#'s.
3109fn ts_member_is_public(node: Node, src: &[u8]) -> bool {
3110    match ts_accessibility(node, src) {
3111        None => true,
3112        Some(a) => a == "public",
3113    }
3114}
3115
3116fn class_bases(node: Node, src: &[u8]) -> String {
3117    let Some(heritage) = named_children(node)
3118        .into_iter()
3119        .find(|c| c.kind() == "class_heritage")
3120    else {
3121        return String::new();
3122    };
3123    let mut parts: Vec<String> = Vec::new();
3124    if let Some(extends_clause) = named_children(heritage)
3125        .into_iter()
3126        .find(|c| c.kind() == "extends_clause")
3127    {
3128        if let Some(value) = extends_clause.child_by_field_name("value") {
3129            parts.push(text(value, src));
3130        }
3131    }
3132    if let Some(implements_clause) = named_children(heritage)
3133        .into_iter()
3134        .find(|c| c.kind() == "implements_clause")
3135    {
3136        for t in named_children(implements_clause) {
3137            parts.push(text(t, src));
3138        }
3139    }
3140    parts.join(", ")
3141}
3142
3143fn class_method_names(node: Node, src: &[u8]) -> Vec<String> {
3144    let Some(body) = node.child_by_field_name("body") else {
3145        return Vec::new();
3146    };
3147    named_children(body)
3148        .into_iter()
3149        .filter(|c| c.kind() == "method_definition" && ts_member_is_public(*c, src))
3150        .map(|c| ts_declared_name(c, src))
3151        .filter(|name| !name.is_empty() && name != "constructor")
3152        .collect()
3153}
3154
3155fn interface_bases(node: Node, src: &[u8]) -> String {
3156    let Some(ext) = named_children(node)
3157        .into_iter()
3158        .find(|c| c.kind() == "extends_type_clause")
3159    else {
3160        return String::new();
3161    };
3162    named_children(ext)
3163        .into_iter()
3164        .map(|c| text(c, src))
3165        .collect::<Vec<_>>()
3166        .join(", ")
3167}
3168
3169fn interface_method_names(node: Node, src: &[u8]) -> Vec<String> {
3170    let Some(body) = node.child_by_field_name("body") else {
3171        return Vec::new();
3172    };
3173    named_children(body)
3174        .into_iter()
3175        .filter(|c| c.kind() == "method_signature")
3176        .map(|c| ts_declared_name(c, src))
3177        .filter(|n| !n.is_empty())
3178        .collect()
3179}
3180
3181// Collects bound identifiers out of a destructuring pattern in
3182// pattern-written order, never resolving the RHS of
3183// `export const { a, b } = X;`. Handles the object/array pattern shapes
3184// tree-sitter-typescript actually produces; a shape this doesn't recognise
3185// yields no names rather than a guess.
3186fn ts_pattern_names(node: Option<Node>, src: &[u8]) -> Vec<String> {
3187    let Some(node) = node else {
3188        return Vec::new();
3189    };
3190    match node.kind() {
3191        "identifier" => vec![text(node, src)],
3192        "object_pattern" => {
3193            let mut names = Vec::new();
3194            for c in named_children(node) {
3195                match c.kind() {
3196                    "shorthand_property_identifier_pattern" => names.push(text(c, src)),
3197                    "pair_pattern" => {
3198                        names.extend(ts_pattern_names(c.child_by_field_name("value"), src))
3199                    }
3200                    "rest_pattern" => {
3201                        names.extend(ts_pattern_names(named_children(c).into_iter().next(), src))
3202                    }
3203                    "object_assignment_pattern" => {
3204                        names.extend(ts_pattern_names(c.child_by_field_name("left"), src))
3205                    }
3206                    _ => {}
3207                }
3208            }
3209            names
3210        }
3211        "array_pattern" => named_children(node)
3212            .into_iter()
3213            .flat_map(|c| ts_pattern_names(Some(c), src))
3214            .collect(),
3215        "rest_pattern" => ts_pattern_names(named_children(node).into_iter().next(), src),
3216        _ => Vec::new(),
3217    }
3218}
3219
3220// `export default <expr>;` parses as an export_statement with a `value`
3221// field and no `declaration` field. The only shapes that resolve to a real
3222// name are a bare identifier (`export default Identifier;`) -- literal
3223// token text, no semantic resolution -- everything else (arrow function,
3224// anonymous function/class expression, member/call expression, object/array
3225// literal) is the literal name "(anonymous)".
3226fn ts_default_expression_entry(value_node: Node, src: &[u8]) -> TsEntry {
3227    let name = if value_node.kind() == "identifier" {
3228        text(value_node, src)
3229    } else {
3230        "(anonymous)".to_string()
3231    };
3232    TsEntry {
3233        kind: "default",
3234        name,
3235        bases: String::new(),
3236        methods: Vec::new(),
3237    }
3238}
3239
3240fn ts_entries_for_declaration(decl_node: Node, is_default: bool, src: &[u8]) -> Vec<TsEntry> {
3241    match decl_node.kind() {
3242        "class_declaration" => {
3243            let name = ts_declared_name(decl_node, src);
3244            if name.is_empty() {
3245                Vec::new()
3246            } else {
3247                vec![TsEntry {
3248                    kind: if is_default { "default" } else { "class" },
3249                    name,
3250                    bases: class_bases(decl_node, src),
3251                    methods: class_method_names(decl_node, src),
3252                }]
3253            }
3254        }
3255        "function_declaration" => {
3256            let name = ts_declared_name(decl_node, src);
3257            if name.is_empty() {
3258                Vec::new()
3259            } else {
3260                vec![TsEntry {
3261                    kind: if is_default { "default" } else { "function" },
3262                    name,
3263                    bases: String::new(),
3264                    methods: Vec::new(),
3265                }]
3266            }
3267        }
3268        "interface_declaration" => {
3269            let name = ts_declared_name(decl_node, src);
3270            if name.is_empty() {
3271                Vec::new()
3272            } else {
3273                vec![TsEntry {
3274                    kind: "interface",
3275                    name,
3276                    bases: interface_bases(decl_node, src),
3277                    methods: interface_method_names(decl_node, src),
3278                }]
3279            }
3280        }
3281        "type_alias_declaration" => {
3282            let name = ts_declared_name(decl_node, src);
3283            if name.is_empty() {
3284                Vec::new()
3285            } else {
3286                vec![TsEntry {
3287                    kind: "type",
3288                    name,
3289                    bases: String::new(),
3290                    methods: Vec::new(),
3291                }]
3292            }
3293        }
3294        "enum_declaration" => {
3295            let name = ts_declared_name(decl_node, src);
3296            if name.is_empty() {
3297                Vec::new()
3298            } else {
3299                vec![TsEntry {
3300                    kind: "enum",
3301                    name,
3302                    bases: String::new(),
3303                    methods: Vec::new(),
3304                }]
3305            }
3306        }
3307        // `export default const x = 1;` is not legal syntax, so a
3308        // lexical/var declaration is never the wrapped declaration of a
3309        // default export -- `is_default` is not consulted here.
3310        "lexical_declaration" | "variable_declaration" => {
3311            let mut entries = Vec::new();
3312            for decl in named_children(decl_node) {
3313                if decl.kind() != "variable_declarator" {
3314                    continue;
3315                }
3316                for name in ts_pattern_names(decl.child_by_field_name("name"), src) {
3317                    entries.push(TsEntry {
3318                        kind: "const",
3319                        name,
3320                        bases: String::new(),
3321                        methods: Vec::new(),
3322                    });
3323                }
3324            }
3325            entries
3326        }
3327        _ => Vec::new(),
3328    }
3329}
3330
3331// The `default` keyword in `export default ...` is an unnamed token (no
3332// field name), so it has to be found by scanning ALL raw children (not just
3333// named ones) rather than `child_by_field_name`.
3334fn is_default_export_statement(node: Node) -> bool {
3335    let mut cursor = node.walk();
3336    let found = node.children(&mut cursor).any(|c| c.kind() == "default");
3337    found
3338}
3339
3340fn ts_esm_entries(export_stmts: &[Node], src: &[u8]) -> Vec<TsEntry> {
3341    let mut entries = Vec::new();
3342    for stmt in export_stmts {
3343        if let Some(decl_node) = stmt.child_by_field_name("declaration") {
3344            entries.extend(ts_entries_for_declaration(
3345                decl_node,
3346                is_default_export_statement(*stmt),
3347                src,
3348            ));
3349            continue;
3350        }
3351        if let Some(value_node) = stmt.child_by_field_name("value") {
3352            entries.push(ts_default_expression_entry(value_node, src));
3353        }
3354        // Anything else (`export { a, b };` with no `from` clause) has no
3355        // declaration and no value field, so it is skipped rather than
3356        // guessed at. Re-exports (`export * from './x';`) are collected
3357        // separately, by `ts_reexport_names`.
3358    }
3359    entries
3360}
3361
3362enum CjsTarget {
3363    Whole,
3364    Prop(String),
3365}
3366
3367// `module.exports = X` / `module.exports.foo = X` / `exports.foo = X` --
3368// `Whole` for the first shape, `Prop(name)` for the other two, or `None`
3369// when `left` isn't one of these three recognised shapes.
3370fn common_js_export_target(left: Option<Node>, src: &[u8]) -> Option<CjsTarget> {
3371    let left = left?;
3372    if left.kind() != "member_expression" {
3373        return None;
3374    }
3375    let obj = left.child_by_field_name("object")?;
3376    let prop = left.child_by_field_name("property")?;
3377    if obj.kind() == "identifier" && text(obj, src) == "module" && text(prop, src) == "exports" {
3378        return Some(CjsTarget::Whole);
3379    }
3380    if obj.kind() == "identifier" && text(obj, src) == "exports" {
3381        return Some(CjsTarget::Prop(text(prop, src)));
3382    }
3383    if obj.kind() == "member_expression" {
3384        let outer_obj = obj.child_by_field_name("object");
3385        let outer_prop = obj.child_by_field_name("property");
3386        if let (Some(oo), Some(op)) = (outer_obj, outer_prop) {
3387            if oo.kind() == "identifier" && text(oo, src) == "module" && text(op, src) == "exports"
3388            {
3389                return Some(CjsTarget::Prop(text(prop, src)));
3390            }
3391        }
3392    }
3393    None
3394}
3395
3396struct LocalKind {
3397    kind: &'static str,
3398    bases: String,
3399    methods: Vec<String>,
3400}
3401
3402// A CommonJS export-by-name mirrors the referenced top-level local
3403// declaration if one exists by that name, else a bare `const Name` -- this is
3404// the lookup table for that mirroring, built once per file from the plain
3405// (non-exported, since CommonJS files carry no `export` keyword at all)
3406// top-level declarations.
3407fn ts_local_declaration_kinds(program_node: Node, src: &[u8]) -> HashMap<String, LocalKind> {
3408    let mut kinds = HashMap::new();
3409    for c in named_children(program_node) {
3410        match c.kind() {
3411            "function_declaration" => {
3412                let name = ts_declared_name(c, src);
3413                if !name.is_empty() {
3414                    kinds.insert(
3415                        name,
3416                        LocalKind {
3417                            kind: "function",
3418                            bases: String::new(),
3419                            methods: Vec::new(),
3420                        },
3421                    );
3422                }
3423            }
3424            "class_declaration" => {
3425                let name = ts_declared_name(c, src);
3426                if !name.is_empty() {
3427                    kinds.insert(
3428                        name,
3429                        LocalKind {
3430                            kind: "class",
3431                            bases: class_bases(c, src),
3432                            methods: class_method_names(c, src),
3433                        },
3434                    );
3435                }
3436            }
3437            "lexical_declaration" | "variable_declaration" => {
3438                for decl in named_children(c) {
3439                    if decl.kind() != "variable_declarator" {
3440                        continue;
3441                    }
3442                    if let Some(name_node) = decl.child_by_field_name("name") {
3443                        if name_node.kind() == "identifier" {
3444                            kinds.insert(
3445                                text(name_node, src),
3446                                LocalKind {
3447                                    kind: "const",
3448                                    bases: String::new(),
3449                                    methods: Vec::new(),
3450                                },
3451                            );
3452                        }
3453                    }
3454                }
3455            }
3456            _ => {}
3457        }
3458    }
3459    kinds
3460}
3461
3462fn property_export_entry(name: String, local_kinds: &HashMap<String, LocalKind>) -> TsEntry {
3463    match local_kinds.get(&name) {
3464        Some(local) => TsEntry {
3465            kind: local.kind,
3466            name,
3467            bases: local.bases.clone(),
3468            methods: local.methods.clone(),
3469        },
3470        None => TsEntry {
3471            kind: "const",
3472            name,
3473            bases: String::new(),
3474            methods: Vec::new(),
3475        },
3476    }
3477}
3478
3479fn object_literal_export_entries(
3480    obj_node: Node,
3481    local_kinds: &HashMap<String, LocalKind>,
3482    src: &[u8],
3483) -> Vec<TsEntry> {
3484    let mut entries = Vec::new();
3485    for c in named_children(obj_node) {
3486        match c.kind() {
3487            "shorthand_property_identifier" => {
3488                entries.push(property_export_entry(text(c, src), local_kinds))
3489            }
3490            "pair" => {
3491                let Some(key) = c.child_by_field_name("key") else {
3492                    continue;
3493                };
3494                let name = if key.kind() == "string" {
3495                    named_children(key).into_iter().next().map(|n| text(n, src))
3496                } else {
3497                    Some(text(key, src))
3498                };
3499                if let Some(name) = name {
3500                    entries.push(property_export_entry(name, local_kinds));
3501                }
3502            }
3503            _ => {}
3504        }
3505    }
3506    entries
3507}
3508
3509fn ts_cjs_entries(program_node: Node, src: &[u8]) -> Vec<TsEntry> {
3510    let local_kinds = ts_local_declaration_kinds(program_node, src);
3511    let mut entries = Vec::new();
3512    for stmt in named_children(program_node) {
3513        if stmt.kind() != "expression_statement" {
3514            continue;
3515        }
3516        let Some(assign) = named_children(stmt)
3517            .into_iter()
3518            .find(|n| n.kind() == "assignment_expression")
3519        else {
3520            continue;
3521        };
3522        let Some(target) = common_js_export_target(assign.child_by_field_name("left"), src) else {
3523            continue;
3524        };
3525        let right = assign.child_by_field_name("right");
3526        match target {
3527            CjsTarget::Whole => match right {
3528                Some(r) if r.kind() == "object" => {
3529                    entries.extend(object_literal_export_entries(r, &local_kinds, src))
3530                }
3531                Some(r) => entries.push(ts_default_expression_entry(r, src)),
3532                // An assignment_expression with no `right` field is not
3533                // producible by valid JS syntax -- never guess, skip.
3534                None => {}
3535            },
3536            CjsTarget::Prop(name) => entries.push(property_export_entry(name, &local_kinds)),
3537        }
3538    }
3539    entries
3540}
3541
3542// A file with at least one ESM export_statement is treated as ESM (its
3543// CommonJS-shaped statements, if any, are never scanned); a file with zero
3544// export_statements falls to the CommonJS path: a file with no `export`
3545// keyword at all is treated as CommonJS.
3546fn ts_export_entries(program_node: Node, src: &[u8]) -> Vec<TsEntry> {
3547    let export_stmts: Vec<Node> = named_children(program_node)
3548        .into_iter()
3549        .filter(|c| c.kind() == "export_statement")
3550        .collect();
3551    if export_stmts.is_empty() {
3552        ts_cjs_entries(program_node, src)
3553    } else {
3554        ts_esm_entries(&export_stmts, src)
3555    }
3556}
3557
3558// Re-export ("barrel") bucket: `export { A, B } from 'm'`,
3559// `export * from 'm'`, `export * as NS from 'm'`. Collected as plain name
3560// tokens, in source order across every re-export statement in the file,
3561// deduped by a single set, like every other bucket, just scoped to this
3562// bucket rather than the file-wide seen_methods set.
3563// `export { A as B } from 'm'` contributes the EXPORTED (alias) name B, not
3564// the local name A -- what this barrel's own consumers actually import. A
3565// bare `export * from 'm'` contributes the literal token "*"; the same dedupe
3566// collapses it to one occurrence regardless of how many bare `export *`
3567// statements the file has. Local `export { A };` (no `from` clause -- no
3568// `source` field) is skipped by ts_esm_entries above: it has neither a
3569// `declaration` nor a `value` field.
3570fn push_reexport_name(name: String, names: &mut Vec<String>, seen: &mut HashSet<String>) {
3571    if seen.insert(name.clone()) {
3572        names.push(name);
3573    }
3574}
3575
3576fn ts_reexport_names(program_node: Node, src: &[u8]) -> Vec<String> {
3577    let mut names = Vec::new();
3578    let mut seen: HashSet<String> = HashSet::new();
3579    for stmt in named_children(program_node) {
3580        if stmt.kind() != "export_statement" {
3581            continue;
3582        }
3583        if stmt.child_by_field_name("source").is_none() {
3584            continue; // not a re-export (local `export { A };`, or a declaration/default export)
3585        }
3586        if let Some(ns_export) = named_children(stmt)
3587            .into_iter()
3588            .find(|c| c.kind() == "namespace_export")
3589        {
3590            if let Some(id) = named_children(ns_export)
3591                .into_iter()
3592                .find(|c| c.kind() == "identifier")
3593            {
3594                push_reexport_name(text(id, src), &mut names, &mut seen);
3595            }
3596            continue;
3597        }
3598        if let Some(clause) = named_children(stmt)
3599            .into_iter()
3600            .find(|c| c.kind() == "export_clause")
3601        {
3602            for spec in named_children(clause) {
3603                if spec.kind() != "export_specifier" {
3604                    continue;
3605                }
3606                let alias = spec.child_by_field_name("alias").map(|n| text(n, src));
3607                let name = spec.child_by_field_name("name").map(|n| text(n, src));
3608                if let Some(chosen) = alias.or(name) {
3609                    push_reexport_name(chosen, &mut names, &mut seen);
3610                }
3611            }
3612            continue;
3613        }
3614        // bare `export * from 'm';` -- no export_clause, no namespace_export
3615        push_reexport_name("*".to_string(), &mut names, &mut seen);
3616    }
3617    names
3618}
3619
3620fn reexports_bucket_segment(program_node: Node, src: &[u8]) -> Option<String> {
3621    let names = ts_reexport_names(program_node, src);
3622    if names.is_empty() {
3623        None
3624    } else {
3625        Some(format!("reexports {}", names.join(", ")))
3626    }
3627}
3628
3629const TS_BUCKET_ORDER: &[&str] = &[
3630    "class",
3631    "function",
3632    "const",
3633    "interface",
3634    "type",
3635    "enum",
3636    "default",
3637];
3638
3639// Raw (pre-truncation) purpose segments, joined with " | " -- the SAME
3640// join `compose_ts_purpose` truncates, exposed separately so the hybrid
3641// comment-prefix composition (`extract_ts_purpose_with_heuristic` below) can
3642// apply the SAME global `truncate()` to `<comment> — <segments>` as a whole,
3643// rather than truncating twice. Returns `None` exactly when there is nothing
3644// to report at all -- no exported declarations AND no re-export statements --
3645// a barrel-only file contributes its reexports bucket.
3646fn ts_purpose_segments(program_node: Node, src: &[u8]) -> Option<String> {
3647    let entries = ts_export_entries(program_node, src);
3648    let mut sorted: Vec<&TsEntry> = Vec::with_capacity(entries.len());
3649    for kind in TS_BUCKET_ORDER {
3650        sorted.extend(entries.iter().filter(|e| e.kind == *kind));
3651    }
3652    let reexports_segment = reexports_bucket_segment(program_node, src);
3653    if sorted.is_empty() && reexports_segment.is_none() {
3654        return None;
3655    }
3656    // Same single shared Set, in bucket-emission order, as C#'s
3657    // compose_signature -- inherited as-is, known consequence and all (a
3658    // class implementing an interface earlier in bucket order usually
3659    // empties the interface's own member list, since the class already
3660    // claimed those names; see the OrderService.ts fixture).
3661    let mut seen_methods: HashSet<String> = HashSet::new();
3662    let mut segments: Vec<String> = Vec::new();
3663    for e in sorted {
3664        let mut methods = Vec::new();
3665        for m in &e.methods {
3666            if seen_methods.contains(m) {
3667                continue;
3668            }
3669            seen_methods.insert(m.clone());
3670            methods.push(m.clone());
3671        }
3672        let header = if e.bases.is_empty() {
3673            format!("{} {}", e.kind, e.name)
3674        } else {
3675            format!("{} {} : {}", e.kind, e.name, e.bases)
3676        };
3677        segments.push(if methods.is_empty() {
3678            header
3679        } else {
3680            format!("{header}; {}", methods.join(", "))
3681        });
3682    }
3683    // Reexports is the final bucket, after default -- appended
3684    // once, never bucket-sorted alongside the TsEntry-derived segments above
3685    // since it is a single pre-joined string, not a per-entry segment.
3686    if let Some(seg) = reexports_segment {
3687        segments.push(seg);
3688    }
3689    Some(segments.join(" | "))
3690}
3691
3692fn compose_ts_purpose(program_node: Node, src: &[u8]) -> Option<String> {
3693    ts_purpose_segments(program_node, src).map(|s| truncate(&s))
3694}
3695
3696/// Compose a leading-comment hybrid purpose prefix. `comment_text` is
3697/// `walk::default_purpose_detailed`'s output (already ≤100 chars); the
3698/// SAME global `truncate()` every other purpose bucket uses applies to the
3699/// combined string over the RAW (pre-truncation) segment join, so the AST
3700/// tail fills the remainder under the 200-char cap instead of compounding
3701/// two independent truncations.
3702pub fn compose_hybrid_ts_purpose(comment_text: &str, raw_segments: &str) -> String {
3703    truncate(&format!("{comment_text} — {raw_segments}"))
3704}
3705
3706/// Extract a one-line purpose signature from TS/JS source, or `None` when the
3707/// file exports nothing or fails to parse (in which case the caller falls back
3708/// to the heuristic purpose). `grammar` comes from `parse::ts_grammar_for`.
3709/// PURE -- no heuristic or disk access; see `extract_ts_purpose_with_heuristic`
3710/// below for the hybrid-aware entry point that `mapcmd.rs` and
3711/// `run_extract_dump` actually use.
3712pub fn extract_ts_purpose(source: &str, grammar: crate::parse::TsGrammar) -> Option<String> {
3713    let units = crate::parse::utf16_units(source);
3714    let tree = crate::parse::parse_ts_js(&units, grammar)?;
3715    compose_ts_purpose(tree.root_node(), &crate::parse::utf16_bytes(&units))
3716}
3717
3718/// Hybrid-aware entry point used by `mapcmd.rs`'s per-file dispatch. Returns
3719/// `None` exactly when `extract_ts_purpose` would (zero-export or parse
3720/// failure) -- a zero-export file has no purpose to prefix. When there IS a
3721/// purpose, `root`/`rel` are used ONLY to ask `walk::default_purpose_detailed`
3722/// whether the heuristic's match for this file came from the comment-marker
3723/// branch; if so, the comment text is prefixed via `compose_hybrid_ts_purpose`
3724/// before the final truncate, else the plain (unprefixed) purpose is returned
3725/// exactly as `extract_ts_purpose` would produce it.
3726pub fn extract_ts_purpose_with_heuristic(
3727    root: &Path,
3728    rel: &str,
3729    source: &str,
3730    grammar: crate::parse::TsGrammar,
3731) -> Option<String> {
3732    let units = crate::parse::utf16_units(source);
3733    let tree = crate::parse::parse_ts_js(&units, grammar)?;
3734    let raw = ts_purpose_segments(tree.root_node(), &crate::parse::utf16_bytes(&units))?;
3735    let detail = crate::walk::default_purpose_detailed(root, rel);
3736    Some(if detail.is_comment {
3737        compose_hybrid_ts_purpose(&detail.text, &raw)
3738    } else {
3739        truncate(&raw)
3740    })
3741}
3742
3743// ---------------------------------------------------------------------------
3744// TS/TSX reference facts (imports, calls, JSX uses, dispatches) and their
3745// helpers.
3746//
3747// Same tree the purpose composition above already parsed, one extra walk, no
3748// second parse. This section records only what a file SAYS: which specifiers
3749// it imports and under which local names, which top-level names it exports,
3750// and which local names it calls / renders as a JSX tag / hands to a
3751// dispatching call. Nothing here resolves a specifier to a file or a name to
3752// a declaration -- tsgraph.rs does that across files, the same split
3753// `extract`/`resolve_graph` already uses for C#.
3754//
3755// A TS fragment is tagged `ts: 1` (see `graph::TsFragment`) so the resolver
3756// split in `resolve_graph` can route it to the TS resolver instead of the C#
3757// one: the two fragment shapes share no field beyond `defs`, and feeding one
3758// to the other's resolver would resolve names across languages that have no
3759// relationship at all.
3760// ---------------------------------------------------------------------------
3761
3762/// One exported top-level declaration, in the shape the fragment serializes:
3763/// `name`, `kind`, `line`, in that order.
3764#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3765pub struct TsFragmentDef {
3766    /// The name value.
3767    pub name: String,
3768    /// The kind value.
3769    pub kind: String,
3770    /// The line value.
3771    pub line: usize,
3772    #[serde(rename = "endLine")]
3773    /// The end line value.
3774    pub end_line: usize,
3775}
3776
3777/// One local name an import statement binds, and the export it names in the
3778/// source module. `imported` is `"default"` for a default clause and `"*"`
3779/// for a namespace clause (or a whole-module `require`).
3780#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3781pub struct TsBinding {
3782    /// The local value.
3783    pub local: String,
3784    /// The imported value.
3785    pub imported: String,
3786}
3787
3788#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3789/// Represents `TsImport`.
3790pub struct TsImport {
3791    /// The spec value.
3792    pub spec: String,
3793    /// The line value.
3794    pub line: usize,
3795    /// The bindings value.
3796    pub bindings: Vec<TsBinding>,
3797}
3798
3799/// `export { A as B } from 'm'` re-exports A under the name B: `exported` is
3800/// what a consumer of THIS file imports, `imported` is what the source module
3801/// declares.
3802#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3803pub struct TsReexportName {
3804    /// The exported value.
3805    pub exported: String,
3806    /// The imported value.
3807    pub imported: String,
3808}
3809
3810#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3811/// Represents `TsReexport`.
3812pub struct TsReexport {
3813    /// The spec value.
3814    pub spec: String,
3815    /// The line value.
3816    pub line: usize,
3817    /// The star value.
3818    pub star: bool,
3819    /// The names value.
3820    pub names: Vec<TsReexportName>,
3821}
3822
3823/// One recorded reference. Field order (`kind`, `name`, optional `member`,
3824/// `line`) is significant.
3825#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3826pub struct TsRef {
3827    /// The kind value.
3828    pub kind: String,
3829    /// The name value.
3830    pub name: String,
3831    /// Present only on a qualified reference (`ns.member(...)`,
3832    /// `<Ns.Thing />`), and serialized between `name` and `line` exactly
3833    /// where the JS object literal writes it.
3834    #[serde(default, skip_serializing_if = "Option::is_none")]
3835    pub member: Option<String>,
3836    /// The line value.
3837    pub line: usize,
3838}
3839
3840/// The whole per-file fragment, in serialization order: `defs`, `imports`,
3841/// `reexports`, `refs`, then `default` when the file has one. The `ts: 1` tag
3842/// itself lives on the serde type (`graph::TsFragment`), which this converts
3843/// into -- exactly as `extract::Extraction` converts into `graph::Fragment`.
3844#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
3845pub struct TsFragment {
3846    /// The routing tag, always `1` and always FIRST -- `resolve_graph`'s
3847    /// door-level split reads it to send this fragment to the TS resolver
3848    /// instead of the C# one, and `graph::AnyFragment` reads it to tell the
3849    /// two cached shapes apart. Required on the read side too: a C#/markup
3850    /// fragment carries no `ts` key at all, which is what makes the untagged
3851    /// discrimination total.
3852    pub ts: u8,
3853    /// The defs value.
3854    pub defs: Vec<TsFragmentDef>,
3855    /// The imports value.
3856    pub imports: Vec<TsImport>,
3857    /// The reexports value.
3858    pub reexports: Vec<TsReexport>,
3859    /// The refs value.
3860    pub refs: Vec<TsRef>,
3861    /// Appended LAST and only when the file has one -- the house rule for
3862    /// every added fact.
3863    #[serde(default, skip_serializing_if = "Option::is_none")]
3864    pub default: Option<String>,
3865}
3866
3867impl Default for TsFragment {
3868    fn default() -> Self {
3869        TsFragment {
3870            ts: 1,
3871            defs: Vec::new(),
3872            imports: Vec::new(),
3873            reexports: Vec::new(),
3874            refs: Vec::new(),
3875            default: None,
3876        }
3877    }
3878}
3879
3880// The dispatching call names, framework-specific BY NAME on purpose.
3881// `dispatch(x)` covers redux
3882// and NgRx `Store.dispatch`; `ofType(x)` is how an NgRx effect names the
3883// actions it reacts to. A member call ending in `.dispatch(...)` counts too,
3884// by the property name alone -- the receiver is never resolved, so this can
3885// never claim more than the call site literally spells.
3886const TS_DISPATCH_CALLEES: &[&str] = &["dispatch", "ofType"];
3887
3888// The four node kinds a reference can be spelled as. The traversal below is
3889// cursor-driven and materialises a `Node` ONLY for these.
3890const TS_REFERENCE_NODES: &[&str] = &[
3891    "call_expression",
3892    "new_expression",
3893    "jsx_opening_element",
3894    "jsx_self_closing_element",
3895];
3896
3897// `None` for anything that is not a `string` node, and the empty string for
3898// a string with no `string_fragment` child.
3899fn ts_string_literal(node: Option<Node>, src: &[u8]) -> Option<String> {
3900    let node = node?;
3901    if node.kind() != "string" {
3902        return None;
3903    }
3904    Some(
3905        match named_children(node)
3906            .into_iter()
3907            .find(|c| c.kind() == "string_fragment")
3908        {
3909            Some(frag) => text(frag, src),
3910            None => String::new(),
3911        },
3912    )
3913}
3914
3915fn ts_line(node: Node) -> usize {
3916    node.start_position().row + 1
3917}
3918
3919// A JSX tag names a component (rather than an intrinsic HTML element) exactly
3920// when its first character is an ASCII uppercase letter -- React's own rule,
3921// applied literally rather than guessed at. ASCII-only, so the classification
3922// of a non-ASCII first character is stable (never a component).
3923fn is_component_tag_name(name: &str) -> bool {
3924    matches!(name.chars().next(), Some(c) if c.is_ascii_uppercase())
3925}
3926
3927fn ts_import_bindings(stmt: Node, src: &[u8]) -> Vec<TsBinding> {
3928    let Some(clause) = named_children(stmt)
3929        .into_iter()
3930        .find(|c| c.kind() == "import_clause")
3931    else {
3932        return Vec::new();
3933    };
3934    let mut out = Vec::new();
3935    for c in named_children(clause) {
3936        match c.kind() {
3937            "identifier" => out.push(TsBinding {
3938                local: text(c, src),
3939                imported: "default".to_string(),
3940            }),
3941            "namespace_import" => {
3942                if let Some(id) = named_children(c)
3943                    .into_iter()
3944                    .find(|n| n.kind() == "identifier")
3945                {
3946                    out.push(TsBinding {
3947                        local: text(id, src),
3948                        imported: "*".to_string(),
3949                    });
3950                }
3951            }
3952            "named_imports" => {
3953                for spec in named_children(c) {
3954                    if spec.kind() != "import_specifier" {
3955                        continue;
3956                    }
3957                    let Some(name) = spec.child_by_field_name("name") else {
3958                        continue;
3959                    };
3960                    let alias = spec.child_by_field_name("alias");
3961                    out.push(TsBinding {
3962                        local: text(alias.unwrap_or(name), src),
3963                        imported: text(name, src),
3964                    });
3965                }
3966            }
3967            _ => {}
3968        }
3969    }
3970    out
3971}
3972
3973// A bare `export * from 'm'` carries no names and is marked `star`. `export *
3974// as NS from 'm'` binds a namespace OBJECT under NS -- resolving `NS.member`
3975// through it would be a second hop past the one level the resolver follows,
3976// so it contributes its import edge and no name mapping (neither `star` nor
3977// any `names` entry).
3978fn ts_reexport_entry(stmt: Node, src: &[u8]) -> Option<TsReexport> {
3979    let spec = ts_string_literal(stmt.child_by_field_name("source"), src)?;
3980    let line = ts_line(stmt);
3981    if named_children(stmt)
3982        .iter()
3983        .any(|c| c.kind() == "namespace_export")
3984    {
3985        return Some(TsReexport {
3986            spec,
3987            line,
3988            star: false,
3989            names: Vec::new(),
3990        });
3991    }
3992    let Some(clause) = named_children(stmt)
3993        .into_iter()
3994        .find(|c| c.kind() == "export_clause")
3995    else {
3996        return Some(TsReexport {
3997            spec,
3998            line,
3999            star: true,
4000            names: Vec::new(),
4001        });
4002    };
4003    let mut names = Vec::new();
4004    for s in named_children(clause) {
4005        if s.kind() != "export_specifier" {
4006            continue;
4007        }
4008        let Some(name) = s.child_by_field_name("name") else {
4009            continue;
4010        };
4011        let alias = s.child_by_field_name("alias");
4012        names.push(TsReexportName {
4013            exported: text(alias.unwrap_or(name), src),
4014            imported: text(name, src),
4015        });
4016    }
4017    Some(TsReexport {
4018        spec,
4019        line,
4020        star: false,
4021        names,
4022    })
4023}
4024
4025// The kind vocabulary is `TS_BUCKET_ORDER` so a TS def row reads
4026// the same way a TS purpose segment does. `default` is not among them: a
4027// default export keeps the kind of what it declares, and the file records the
4028// local name it exported by default separately.
4029fn ts_decl_kind(node: Node) -> Option<&'static str> {
4030    match node.kind() {
4031        "class_declaration" | "abstract_class_declaration" => Some("class"),
4032        "function_declaration" | "generator_function_declaration" => Some("function"),
4033        "interface_declaration" => Some("interface"),
4034        "type_alias_declaration" => Some("type"),
4035        "enum_declaration" => Some("enum"),
4036        "lexical_declaration" | "variable_declaration" => Some("const"),
4037        _ => None,
4038    }
4039}
4040
4041#[derive(Debug, Clone)]
4042struct TsDecl {
4043    kind: &'static str,
4044    line: usize,
4045    end_line: usize,
4046}
4047
4048// Every top-level declaration with the line its NAME is declared on, whether
4049// exported or not: `export { A }` names a local declaration that was written
4050// without the keyword, and a CommonJS file exports by name only.
4051fn ts_top_level_decls(program_node: Node, src: &[u8]) -> HashMap<String, TsDecl> {
4052    let mut decls: HashMap<String, TsDecl> = HashMap::new();
4053    fn add(
4054        decls: &mut HashMap<String, TsDecl>,
4055        name: String,
4056        kind: &'static str,
4057        line: usize,
4058        end_line: usize,
4059    ) {
4060        if name.is_empty() {
4061            return;
4062        }
4063        decls.entry(name).or_insert(TsDecl {
4064            kind,
4065            line,
4066            end_line,
4067        });
4068    }
4069    fn from_declaration(node: Node, src: &[u8], decls: &mut HashMap<String, TsDecl>) {
4070        let Some(kind) = ts_decl_kind(node) else {
4071            return;
4072        };
4073        if kind == "const" {
4074            for d in named_children(node) {
4075                if d.kind() != "variable_declarator" {
4076                    continue;
4077                }
4078                let name_node = d.child_by_field_name("name");
4079                let line = ts_line(name_node.unwrap_or(d));
4080                for n in ts_pattern_names(name_node, src) {
4081                    add(decls, n, "const", line, d.end_position().row + 1);
4082                }
4083            }
4084            return;
4085        }
4086        add(
4087            decls,
4088            ts_declared_name(node, src),
4089            kind,
4090            ts_line(node),
4091            node.end_position().row + 1,
4092        );
4093    }
4094    for c in named_children(program_node) {
4095        if c.kind() == "export_statement" {
4096            if let Some(decl) = c.child_by_field_name("declaration") {
4097                from_declaration(decl, src, &mut decls);
4098            }
4099            continue;
4100        }
4101        from_declaration(c, src, &mut decls);
4102    }
4103    decls
4104}
4105
4106fn ts_default_export_name(program_node: Node, src: &[u8]) -> Option<String> {
4107    for stmt in named_children(program_node) {
4108        if stmt.kind() != "export_statement" || !is_default_export_statement(stmt) {
4109            continue;
4110        }
4111        if let Some(decl) = stmt.child_by_field_name("declaration") {
4112            let name = ts_declared_name(decl, src);
4113            if !name.is_empty() {
4114                return Some(name);
4115            }
4116            continue;
4117        }
4118        if let Some(value) = stmt.child_by_field_name("value") {
4119            if value.kind() == "identifier" {
4120                return Some(text(value, src));
4121            }
4122        }
4123    }
4124    None
4125}
4126
4127// The names this file makes importable, each mapped to the top-level
4128// declaration it names. A name with no matching declaration in this file
4129// contributes nothing: there is no line to point a caller at, and inventing
4130// one is a guess.
4131fn ts_exported_names(
4132    program_node: Node,
4133    decls: &HashMap<String, TsDecl>,
4134    src: &[u8],
4135) -> Vec<String> {
4136    let mut names: Vec<String> = Vec::new();
4137    let mut seen: HashSet<String> = HashSet::new();
4138    macro_rules! add {
4139        ($name:expr) => {{
4140            let name: String = $name;
4141            if !name.is_empty() && decls.contains_key(&name) && seen.insert(name.clone()) {
4142                names.push(name);
4143            }
4144        }};
4145    }
4146    let export_stmts: Vec<Node> = named_children(program_node)
4147        .into_iter()
4148        .filter(|c| c.kind() == "export_statement")
4149        .collect();
4150    if !export_stmts.is_empty() {
4151        for stmt in export_stmts {
4152            if stmt.child_by_field_name("source").is_some() {
4153                continue; // re-export, handled separately
4154            }
4155            if let Some(decl) = stmt.child_by_field_name("declaration") {
4156                if ts_decl_kind(decl) == Some("const") {
4157                    for d in named_children(decl) {
4158                        if d.kind() != "variable_declarator" {
4159                            continue;
4160                        }
4161                        for n in ts_pattern_names(d.child_by_field_name("name"), src) {
4162                            add!(n);
4163                        }
4164                    }
4165                } else {
4166                    add!(ts_declared_name(decl, src));
4167                }
4168                continue;
4169            }
4170            if let Some(clause) = named_children(stmt)
4171                .into_iter()
4172                .find(|c| c.kind() == "export_clause")
4173            {
4174                for s in named_children(clause) {
4175                    if s.kind() != "export_specifier" {
4176                        continue;
4177                    }
4178                    // A local `export { A as B }` publishes B, but the
4179                    // DECLARATION it points at is A -- only a name this file
4180                    // actually declares earns a def, so the alias is recorded
4181                    // against A's own line.
4182                    if let Some(name) = s.child_by_field_name("name") {
4183                        add!(text(name, src));
4184                    }
4185                }
4186            }
4187        }
4188        return names;
4189    }
4190    // CommonJS -- no `export` keyword anywhere in the file.
4191    for stmt in named_children(program_node) {
4192        if stmt.kind() != "expression_statement" {
4193            continue;
4194        }
4195        let Some(assign) = named_children(stmt)
4196            .into_iter()
4197            .find(|n| n.kind() == "assignment_expression")
4198        else {
4199            continue;
4200        };
4201        let Some(target) = common_js_export_target(assign.child_by_field_name("left"), src) else {
4202            continue;
4203        };
4204        let right = assign.child_by_field_name("right");
4205        match target {
4206            CjsTarget::Prop(prop) => {
4207                add!(prop);
4208            }
4209            CjsTarget::Whole => match right {
4210                Some(r) if r.kind() == "object" => {
4211                    for c in named_children(r) {
4212                        if c.kind() == "shorthand_property_identifier" {
4213                            add!(text(c, src));
4214                        } else if c.kind() == "pair" {
4215                            if let Some(value) = c.child_by_field_name("value") {
4216                                if value.kind() == "identifier" {
4217                                    add!(text(value, src));
4218                                }
4219                            }
4220                        }
4221                    }
4222                }
4223                Some(r) if r.kind() == "identifier" => {
4224                    add!(text(r, src));
4225                }
4226                _ => {}
4227            },
4228        }
4229    }
4230    names
4231}
4232
4233// `const x = require('m')` / `const { a, b } = require('m')` -- the CommonJS
4234// counterpart of an import clause, recorded in the same shape. A whole-module
4235// binding is `"*"` (the same marker a `* as ns` import uses) because that is
4236// what `require` returns; a destructured one names each property directly.
4237fn ts_require_import(decl_node: Node, src: &[u8]) -> Vec<TsImport> {
4238    let mut out = Vec::new();
4239    for d in named_children(decl_node) {
4240        if d.kind() != "variable_declarator" {
4241            continue;
4242        }
4243        let Some(value) = d.child_by_field_name("value") else {
4244            continue;
4245        };
4246        if value.kind() != "call_expression" {
4247            continue;
4248        }
4249        let Some(fnode) = value.child_by_field_name("function") else {
4250            continue;
4251        };
4252        if fnode.kind() != "identifier" || text(fnode, src) != "require" {
4253            continue;
4254        }
4255        let args = value.child_by_field_name("arguments");
4256        let first = args.and_then(|a| named_children(a).into_iter().next());
4257        let Some(spec) = ts_string_literal(first, src) else {
4258            continue;
4259        };
4260        let name_node = d.child_by_field_name("name");
4261        let bindings = match name_node {
4262            Some(n) if n.kind() == "identifier" => {
4263                vec![TsBinding {
4264                    local: text(n, src),
4265                    imported: "*".to_string(),
4266                }]
4267            }
4268            _ => ts_pattern_names(name_node, src)
4269                .into_iter()
4270                .map(|n| TsBinding {
4271                    local: n.clone(),
4272                    imported: n,
4273                })
4274                .collect(),
4275        };
4276        out.push(TsImport {
4277            spec,
4278            line: ts_line(d),
4279            bindings,
4280        });
4281    }
4282    out
4283}
4284
4285fn ts_record_ref(
4286    node: Node,
4287    src: &[u8],
4288    refs: &mut Vec<TsRef>,
4289    consumed: &mut HashSet<usize>,
4290    known: &HashSet<String>,
4291) {
4292    match node.kind() {
4293        "call_expression" => {
4294            let fnode = node.child_by_field_name("function");
4295            let callee = match fnode {
4296                Some(f) if f.kind() == "identifier" => Some(text(f, src)),
4297                Some(f) if f.kind() == "member_expression" => {
4298                    f.child_by_field_name("property").map(|p| text(p, src))
4299                }
4300                _ => None,
4301            };
4302            let dispatching = callee
4303                .as_deref()
4304                .is_some_and(|c| TS_DISPATCH_CALLEES.contains(&c));
4305            if dispatching {
4306                // `dispatch(loadThings())` names loadThings, not the inner
4307                // call's own callee: the argument IS the action creator or
4308                // thunk, and marking it consumed is what stops the walk from
4309                // ALSO recording it as a plain call. `dispatch(clearCart)` (an
4310                // already-built action object) names it directly.
4311                let args = node
4312                    .child_by_field_name("arguments")
4313                    .map(named_children)
4314                    .unwrap_or_default();
4315                for arg in args {
4316                    if arg.kind() == "identifier" {
4317                        let name = text(arg, src);
4318                        if known.contains(&name) {
4319                            refs.push(TsRef {
4320                                kind: "dispatch".to_string(),
4321                                name,
4322                                member: None,
4323                                line: ts_line(arg),
4324                            });
4325                        }
4326                        consumed.insert(arg.id());
4327                    } else if arg.kind() == "call_expression" {
4328                        if let Some(inner) = arg.child_by_field_name("function") {
4329                            if inner.kind() == "identifier" {
4330                                let name = text(inner, src);
4331                                if known.contains(&name) {
4332                                    refs.push(TsRef {
4333                                        kind: "dispatch".to_string(),
4334                                        name,
4335                                        member: None,
4336                                        line: ts_line(inner),
4337                                    });
4338                                }
4339                                consumed.insert(arg.id());
4340                            }
4341                        }
4342                    }
4343                }
4344            } else if !consumed.contains(&node.id()) {
4345                match fnode {
4346                    Some(f) if f.kind() == "identifier" => {
4347                        let name = text(f, src);
4348                        if known.contains(&name) {
4349                            refs.push(TsRef {
4350                                kind: "call".to_string(),
4351                                name,
4352                                member: None,
4353                                line: ts_line(f),
4354                            });
4355                        }
4356                    }
4357                    Some(f) if f.kind() == "member_expression" => {
4358                        let obj = f.child_by_field_name("object");
4359                        let prop = f.child_by_field_name("property");
4360                        // The QUALIFIER only, never the chain's tail method --
4361                        // the same line the C# side draws: `uses-member`
4362                        // resolves to a type, never to a chained call's
4363                        // method def.
4364                        if let (Some(obj), Some(prop)) = (obj, prop) {
4365                            let name = text(obj, src);
4366                            if obj.kind() == "identifier" && known.contains(&name) {
4367                                refs.push(TsRef {
4368                                    kind: "call".to_string(),
4369                                    name,
4370                                    member: Some(text(prop, src)),
4371                                    line: ts_line(obj),
4372                                });
4373                            }
4374                        }
4375                    }
4376                    _ => {}
4377                }
4378            }
4379        }
4380        "new_expression" => {
4381            if let Some(ctor) = node.child_by_field_name("constructor") {
4382                let name = text(ctor, src);
4383                if ctor.kind() == "identifier" && known.contains(&name) {
4384                    refs.push(TsRef {
4385                        kind: "call".to_string(),
4386                        name,
4387                        member: None,
4388                        line: ts_line(ctor),
4389                    });
4390                }
4391            }
4392        }
4393        "jsx_opening_element" | "jsx_self_closing_element" => {
4394            let name_node = node.child_by_field_name("name");
4395            match name_node {
4396                Some(n) if n.kind() == "identifier" => {
4397                    let name = text(n, src);
4398                    if is_component_tag_name(&name) && known.contains(&name) {
4399                        refs.push(TsRef {
4400                            kind: "jsx-use".to_string(),
4401                            name,
4402                            member: None,
4403                            line: ts_line(n),
4404                        });
4405                    }
4406                }
4407                Some(n) if n.kind() == "member_expression" || n.kind() == "nested_identifier" => {
4408                    let obj = n.child_by_field_name("object");
4409                    let prop = n.child_by_field_name("property");
4410                    if let (Some(obj), Some(prop)) = (obj, prop) {
4411                        let name = text(obj, src);
4412                        if obj.kind() == "identifier"
4413                            && is_component_tag_name(&name)
4414                            && known.contains(&name)
4415                        {
4416                            refs.push(TsRef {
4417                                kind: "jsx-use".to_string(),
4418                                name,
4419                                member: Some(text(prop, src)),
4420                                line: ts_line(obj),
4421                            });
4422                        }
4423                    }
4424                }
4425                _ => {}
4426            }
4427        }
4428        _ => {}
4429    }
4430}
4431
4432// Pre-order, the same order the recursive form visits in: a dispatching call
4433// is always seen before the argument it consumes, which is what lets the
4434// consumed set suppress a duplicate plain-call ref for that argument.
4435// Cursor-driven, materialising a `Node` only for the four reference kinds.
4436fn ts_ref_walk(program_node: Node, src: &[u8], refs: &mut Vec<TsRef>, known: &HashSet<String>) {
4437    let mut consumed: HashSet<usize> = HashSet::new();
4438    let mut cursor = program_node.walk();
4439    loop {
4440        if TS_REFERENCE_NODES.contains(&cursor.node().kind()) {
4441            ts_record_ref(cursor.node(), src, refs, &mut consumed, known);
4442        }
4443        if cursor.goto_first_child() {
4444            continue;
4445        }
4446        let mut advanced = false;
4447        while cursor.depth() > 0 {
4448            if cursor.goto_next_sibling() {
4449                advanced = true;
4450                break;
4451            }
4452            if !cursor.goto_parent() {
4453                break;
4454            }
4455        }
4456        if !advanced && !cursor.goto_next_sibling() {
4457            break;
4458        }
4459    }
4460}
4461
4462/// Extract the reference fragment (imports, exports, refs) from a parsed TS/JS
4463/// program node.
4464pub fn extract_ts_fragment(program_node: Node, src: &[u8]) -> TsFragment {
4465    let decls = ts_top_level_decls(program_node, src);
4466    let mut imports: Vec<TsImport> = Vec::new();
4467    let mut reexports: Vec<TsReexport> = Vec::new();
4468    for stmt in named_children(program_node) {
4469        if stmt.kind() == "import_statement" {
4470            let Some(spec) = ts_string_literal(stmt.child_by_field_name("source"), src) else {
4471                continue;
4472            };
4473            imports.push(TsImport {
4474                spec,
4475                line: ts_line(stmt),
4476                bindings: ts_import_bindings(stmt, src),
4477            });
4478        } else if stmt.kind() == "export_statement" && stmt.child_by_field_name("source").is_some()
4479        {
4480            if let Some(entry) = ts_reexport_entry(stmt, src) {
4481                reexports.push(entry);
4482            }
4483        } else if stmt.kind() == "lexical_declaration" || stmt.kind() == "variable_declaration" {
4484            imports.extend(ts_require_import(stmt, src));
4485        }
4486    }
4487    let defs: Vec<TsFragmentDef> = ts_exported_names(program_node, &decls, src)
4488        .into_iter()
4489        .map(|name| {
4490            let d = &decls[&name];
4491            TsFragmentDef {
4492                name,
4493                kind: d.kind.to_string(),
4494                line: d.line,
4495                end_line: d.end_line,
4496            }
4497        })
4498        .collect();
4499    // The only local names a cross-file resolver could ever land on: something
4500    // this file imported, or something this file itself exports. A reference
4501    // to anything else is unresolvable BY THE RESOLVER'S OWN RULE, so
4502    // recording it would put a fact in the fragment cache that exists only to
4503    // be discarded -- and it changes no edge the resolver would have emitted.
4504    let mut known: HashSet<String> = defs.iter().map(|d| d.name.clone()).collect();
4505    for imp in &imports {
4506        for b in &imp.bindings {
4507            known.insert(b.local.clone());
4508        }
4509    }
4510    let mut refs: Vec<TsRef> = Vec::new();
4511    ts_ref_walk(program_node, src, &mut refs, &known);
4512    // Appended LAST and only when the file has one, the house rule for every
4513    // added fact -- a file with no default export keeps the shorter shape.
4514    let default = ts_default_export_name(program_node, src).filter(|d| decls.contains_key(d));
4515    TsFragment {
4516        ts: 1,
4517        defs,
4518        imports,
4519        reexports,
4520        refs,
4521        default,
4522    }
4523}
4524
4525/// One TS/JS file's whole contribution, off ONE parse: its purpose (when the
4526/// file exports anything) and its reference fragment (always), from the same
4527/// tree. A parse failure yields `None`, leaving the file out of BOTH outputs.
4528pub struct TsFileExtraction {
4529    /// The purpose value.
4530    pub purpose: Option<String>,
4531    /// The fragment value.
4532    pub fragment: TsFragment,
4533}
4534
4535/// Parses TypeScript-family source into its optional purpose and graph fragment.
4536///
4537/// Returns `None` when the selected grammar cannot parse the source.
4538pub fn extract_ts_file(
4539    root: &Path,
4540    rel: &str,
4541    source: &str,
4542    grammar: crate::parse::TsGrammar,
4543) -> Option<TsFileExtraction> {
4544    let units = crate::parse::utf16_units(source);
4545    let tree = crate::parse::parse_ts_js(&units, grammar)?;
4546    let src = crate::parse::utf16_bytes(&units);
4547    let root_node = tree.root_node();
4548    let purpose = ts_purpose_segments(root_node, &src).map(|raw| {
4549        let detail = crate::walk::default_purpose_detailed(root, rel);
4550        if detail.is_comment {
4551            compose_hybrid_ts_purpose(&detail.text, &raw)
4552        } else {
4553            truncate(&raw)
4554        }
4555    });
4556    Some(TsFileExtraction {
4557        purpose,
4558        fragment: extract_ts_fragment(root_node, &src),
4559    })
4560}
4561
4562/// Render a TS/JS file's dump-extract JSON. The reader expects
4563/// `{purpose, defs, usings, refs, names}`, and a TS fragment carries no
4564/// `usings` and no `names`, so both keys are dropped -- the dump is a
4565/// three-key object.
4566pub fn ts_extraction_to_json(purpose: &Option<String>, fragment: &TsFragment) -> String {
4567    let purpose_json = match purpose {
4568        Some(p) => Json::Str(p.clone()),
4569        None => Json::Null,
4570    };
4571    let root = Json::Obj(vec![
4572        ("purpose", purpose_json),
4573        (
4574            "defs",
4575            Json::Arr(
4576                fragment
4577                    .defs
4578                    .iter()
4579                    .map(|d| {
4580                        Json::Obj(vec![
4581                            ("name", Json::Str(d.name.clone())),
4582                            ("kind", Json::Str(d.kind.clone())),
4583                            ("line", Json::Num(d.line)),
4584                        ])
4585                    })
4586                    .collect(),
4587            ),
4588        ),
4589        (
4590            "refs",
4591            Json::Arr(
4592                fragment
4593                    .refs
4594                    .iter()
4595                    .map(|r| {
4596                        let mut fields = vec![
4597                            ("kind", Json::Str(r.kind.clone())),
4598                            ("name", Json::Str(r.name.clone())),
4599                        ];
4600                        if let Some(m) = &r.member {
4601                            fields.push(("member", Json::Str(m.clone())));
4602                        }
4603                        fields.push(("line", Json::Num(r.line)));
4604                        Json::Obj(fields)
4605                    })
4606                    .collect(),
4607            ),
4608        ),
4609    ]);
4610    root.to_pretty_string()
4611}
4612
4613#[cfg(test)]
4614mod tests {
4615    use super::*;
4616
4617    fn extract_src(src: &str) -> Extraction {
4618        extract(src)
4619    }
4620
4621    fn find_def<'a>(e: &'a Extraction, id: &str) -> Option<&'a DefRecord> {
4622        e.defs.iter().find(|d| d.id == id)
4623    }
4624
4625    // --- FQN building (type_id / typeStack "+"-joining) ---------------
4626
4627    #[test]
4628    fn namespace_level_type_fqn_is_dotted() {
4629        let e = extract_src("namespace Fixtures.Widgets { public class Gadget {} }");
4630        assert!(find_def(&e, "Fixtures.Widgets.Gadget").is_some());
4631    }
4632
4633    #[test]
4634    fn top_level_type_fqn_has_no_namespace_prefix() {
4635        let e = extract_src("public class Gadget {}");
4636        let d = find_def(&e, "Gadget").expect("Gadget def present");
4637        assert_eq!(d.namespace, "");
4638    }
4639
4640    #[test]
4641    fn nested_type_fqn_uses_plus_not_dot() {
4642        let e = extract_src(
4643            "namespace Fixtures.Widgets { public class Outer { public class Inner {} } }",
4644        );
4645        assert!(find_def(&e, "Fixtures.Widgets.Outer+Inner").is_some());
4646        // The "+"-joined id must never collide with a literal dotted path.
4647        assert!(find_def(&e, "Fixtures.Widgets.Outer.Inner").is_none());
4648    }
4649
4650    #[test]
4651    fn doubly_nested_type_fqn_chains_plus_joins() {
4652        let e = extract_src(
4653            "namespace Fixtures.Widgets { public class A { public class B { public class C {} } } }",
4654        );
4655        assert!(find_def(&e, "Fixtures.Widgets.A+B+C").is_some());
4656    }
4657
4658    #[test]
4659    fn enum_member_id_appends_dot_even_under_nested_type() {
4660        let e = extract_src(
4661            "namespace Fixtures.Gadgets { public class Controller { public enum State { Off, On } } }",
4662        );
4663        assert!(find_def(&e, "Fixtures.Gadgets.Controller+State").is_some());
4664        assert!(find_def(&e, "Fixtures.Gadgets.Controller+State.Off").is_some());
4665        assert!(find_def(&e, "Fixtures.Gadgets.Controller+State.On").is_some());
4666    }
4667
4668    #[test]
4669    fn file_scoped_namespace_siblings_get_the_namespace() {
4670        let e = extract_src("namespace Fixtures.Gadgets;\npublic class Registry {}\npublic enum State { Off, On }\n");
4671        assert!(find_def(&e, "Fixtures.Gadgets.Registry").is_some());
4672        assert!(find_def(&e, "Fixtures.Gadgets.State").is_some());
4673        assert!(find_def(&e, "Fixtures.Gadgets.State.Off").is_some());
4674    }
4675
4676    #[test]
4677    fn nested_regular_namespace_dots_accumulate() {
4678        let e = extract_src("namespace A { namespace B { public class Widget {} } }");
4679        assert!(find_def(&e, "A.B.Widget").is_some());
4680    }
4681
4682    // --- generic arity (type_argument_list -> one uses-type ref per arg) --
4683
4684    #[test]
4685    fn type_def_end_line_is_the_closing_brace_line_of_the_whole_declaration() {
4686        let e = extract_src("namespace N;\npublic class Widget\n{\n    int n;\n}\n");
4687        let d = find_def(&e, "N.Widget").unwrap();
4688        assert_eq!((d.line, d.end_line), (2, 5));
4689    }
4690
4691    #[test]
4692    fn enum_member_end_line_is_its_own_single_line() {
4693        let e = extract_src("public enum State\n{\n    Off,\n    On,\n}\n");
4694        assert_eq!(
4695            (
4696                find_def(&e, "State.Off").unwrap().line,
4697                find_def(&e, "State.Off").unwrap().end_line
4698            ),
4699            (3, 3)
4700        );
4701        assert_eq!(
4702            (
4703                find_def(&e, "State.On").unwrap().line,
4704                find_def(&e, "State.On").unwrap().end_line
4705            ),
4706            (4, 4)
4707        );
4708    }
4709
4710    #[test]
4711    fn nested_type_span_stays_within_its_own_node() {
4712        let e = extract_src("public class Outer\n{\n    public class Inner { }\n}\n");
4713        assert_eq!(
4714            (
4715                find_def(&e, "Outer").unwrap().line,
4716                find_def(&e, "Outer").unwrap().end_line
4717            ),
4718            (1, 4)
4719        );
4720        assert_eq!(
4721            (
4722                find_def(&e, "Outer+Inner").unwrap().line,
4723                find_def(&e, "Outer+Inner").unwrap().end_line
4724            ),
4725            (3, 3)
4726        );
4727    }
4728
4729    #[test]
4730    fn generic_name_records_base_and_each_type_argument() {
4731        let e = extract_src(
4732            "using System.Collections.Generic;\nnamespace Fixtures.Generics { public class Store { public Dictionary<Key, Value> Items { get; set; } } public class Key {} public class Value {} }",
4733        );
4734        let uses_type_names: Vec<&str> = e
4735            .refs
4736            .iter()
4737            .filter(|r| r.kind == "uses-type")
4738            .map(|r| r.name.as_str())
4739            .collect();
4740        // Dictionary itself (generic_name's base identifier) plus both type
4741        // arguments (arity 2) -- three distinct uses-type refs total from
4742        // one property type.
4743        assert!(uses_type_names.contains(&"Dictionary"));
4744        assert!(uses_type_names.contains(&"Key"));
4745        assert!(uses_type_names.contains(&"Value"));
4746    }
4747
4748    #[test]
4749    fn nested_generic_type_arguments_are_all_recorded() {
4750        let e = extract_src(
4751            "using System.Collections.Generic;\nnamespace Fixtures.Generics { public class Store { public List<Dictionary<string, Gadget>> Items { get; set; } } public class Gadget {} }",
4752        );
4753        let uses_type_names: Vec<&str> = e
4754            .refs
4755            .iter()
4756            .filter(|r| r.kind == "uses-type")
4757            .map(|r| r.name.as_str())
4758            .collect();
4759        assert!(uses_type_names.contains(&"List"));
4760        assert!(uses_type_names.contains(&"Dictionary"));
4761        assert!(uses_type_names.contains(&"Gadget"));
4762        // "string" is a predefined_type -- never a candidate.
4763        assert!(!uses_type_names.contains(&"string"));
4764    }
4765
4766    // --- alias table (using directive parsing) -------------------------
4767
4768    #[test]
4769    fn using_alias_directive_captures_alias_and_target() {
4770        let e = extract_src("using Widgets = Fixtures.Widgets.Catalog;\n");
4771        assert_eq!(e.usings.len(), 1);
4772        match &e.usings[0] {
4773            UsingRecord::Alias {
4774                alias,
4775                target,
4776                global,
4777            } => {
4778                assert_eq!(alias, "Widgets");
4779                assert_eq!(target, "Fixtures.Widgets.Catalog");
4780                assert!(!global);
4781            }
4782            UsingRecord::Plain { .. } => panic!("expected alias form"),
4783        }
4784    }
4785
4786    #[test]
4787    fn plain_using_directive_has_no_alias() {
4788        let e = extract_src("using System.Collections.Generic;\n");
4789        match &e.usings[0] {
4790            UsingRecord::Plain { text, global } => {
4791                assert_eq!(text, "System.Collections.Generic");
4792                assert!(!global);
4793            }
4794            UsingRecord::Alias { .. } => panic!("expected plain form"),
4795        }
4796    }
4797
4798    #[test]
4799    fn global_using_directive_sets_global_flag() {
4800        let e = extract_src("global using System;\n");
4801        match &e.usings[0] {
4802            UsingRecord::Plain { text, global } => {
4803                assert_eq!(text, "System");
4804                assert!(*global);
4805            }
4806            UsingRecord::Alias { .. } => panic!("expected plain form"),
4807        }
4808    }
4809
4810    #[test]
4811    fn static_using_directive_is_plain_form_not_flagged() {
4812        // `using static` is not distinguished from a plain `using` -- both are
4813        // 1-named-child directives.
4814        let e = extract_src("using static System.Math;\n");
4815        match &e.usings[0] {
4816            UsingRecord::Plain { text, global } => {
4817                assert_eq!(text, "System.Math");
4818                assert!(!global);
4819            }
4820            UsingRecord::Alias { .. } => panic!("expected plain form"),
4821        }
4822    }
4823
4824    #[test]
4825    fn using_directive_also_pushes_an_imports_ref_with_null_namespace() {
4826        let e = extract_src("using System.Collections.Generic;\n");
4827        let import_ref = e
4828            .refs
4829            .iter()
4830            .find(|r| r.kind == "imports")
4831            .expect("imports ref present");
4832        assert_eq!(import_ref.name, "System.Collections.Generic");
4833        assert!(import_ref.namespace.is_none());
4834    }
4835
4836    // --- member-access qualifier capture (identifier vs. deep chain) ----
4837
4838    #[test]
4839    fn simple_identifier_qualifier_is_captured_without_dot() {
4840        let e = extract_src(
4841            "namespace Fixtures.Orders { public enum Priority { Low, High } public class Probe { public bool F() { var x = Priority.High; return true; } } }",
4842        );
4843        let m = e
4844            .refs
4845            .iter()
4846            .find(|r| r.kind == "uses-member" && r.member.as_deref() == Some("High"))
4847            .expect("member ref present");
4848        assert_eq!(m.name, "Priority");
4849        assert!(m.qualified.is_none());
4850    }
4851
4852    #[test]
4853    fn deep_member_chain_captures_every_window_qualifier_flattened_at_each_level() {
4854        // `Fixtures.Orders.Priority.High` parses as nested
4855        // member_access_expression, not qualified_name, since this is an
4856        // expression position -- but member_qualifier_text now flattens a
4857        // member_access_expression chain into its full dotted text, so the
4858        // OUTER window ("Fixtures.Orders.Priority" -> "High") is captured
4859        // too, not just the innermost identifier.identifier pair. walk()
4860        // still recurses into every level regardless, so the middle and
4861        // innermost windows are ALSO captured, each as their own separate
4862        // candidate -- resolve.rs's ladder is what decides, per candidate,
4863        // whether any of them actually resolves to an enum.
4864        let e = extract_src(
4865            "namespace Fixtures.Orders { public class Probe { public void F() { var x = Fixtures.Orders.Priority.High; } } }",
4866        );
4867        let members: Vec<(&str, &str)> = e
4868            .refs
4869            .iter()
4870            .filter(|r| r.kind == "uses-member")
4871            .map(|r| (r.name.as_str(), r.member.as_deref().unwrap_or("")))
4872            .collect();
4873        assert_eq!(
4874            members,
4875            vec![
4876                ("Priority", "High"),
4877                ("Orders", "Priority"),
4878                ("Fixtures", "Orders")
4879            ]
4880        );
4881        let outer = e
4882            .refs
4883            .iter()
4884            .find(|r| r.member.as_deref() == Some("High"))
4885            .expect("outer window present");
4886        assert_eq!(outer.qualified.as_deref(), Some("Fixtures.Orders.Priority"));
4887    }
4888
4889    #[test]
4890    fn nested_enum_dotted_qualifier_is_captured_as_a_two_part_qualified_text() {
4891        // "Outer.Inner.On" -- the nested-enum-via-dotted-notation shape (not
4892        // the "+"-joined def id, which only exists on the def/id side, never
4893        // in source text). Extraction only needs to capture the RAW
4894        // qualifier text here; whether it resolves is resolve.rs's ladder.
4895        let e = extract_src(
4896            "namespace Fixtures.Widgets { public class Outer { public enum Inner { Off, On } } public class Probe { public void F() { var x = Outer.Inner.On; } } }",
4897        );
4898        let m = e
4899            .refs
4900            .iter()
4901            .find(|r| r.kind == "uses-member" && r.member.as_deref() == Some("On"))
4902            .expect("outer window present");
4903        assert_eq!(m.name, "Inner");
4904        assert_eq!(m.qualified.as_deref(), Some("Outer.Inner"));
4905    }
4906
4907    // --- declaration_expression (out-declarations) -> uses-type ref -------
4908
4909    #[test]
4910    fn inline_out_declaration_emits_a_uses_type_ref_for_its_type() {
4911        // `Method(out SomeEnum x)` -- a declaration_expression{type, name}
4912        // pair in expression position, the same shape as an ordinary
4913        // `parameter`.
4914        let e = extract_src(
4915            "namespace Fixtures.Orders { public class Probe { public void F() { TryGet(out SomeEnum x); } } }",
4916        );
4917        let uses_type: Vec<&str> = e
4918            .refs
4919            .iter()
4920            .filter(|r| r.kind == "uses-type")
4921            .map(|r| r.name.as_str())
4922            .collect();
4923        assert!(
4924            uses_type.contains(&"SomeEnum"),
4925            "expected a uses-type ref for the out-declared type, got {uses_type:?}"
4926        );
4927    }
4928
4929    #[test]
4930    fn inline_out_declaration_with_var_type_yields_no_candidate() {
4931        // `out var x` -- implicit_type, same as an ordinary `var` parameter:
4932        // never a user-defined type reference, per outer_type_name.
4933        let e = extract_src("namespace Fixtures.Orders { public class Probe { public void F() { TryGet(out var x); } } }");
4934        assert!(e.refs.iter().all(|r| r.kind != "uses-type"));
4935    }
4936
4937    // --- purpose signature ------------------------------------------------
4938
4939    #[test]
4940    fn purpose_signature_composes_kind_name_bases_and_public_methods() {
4941        let e = extract_src(
4942            "namespace Fixtures.Orders { public interface IReader { int Count { get; } } public class Order : IReader { public int Count { get; set; } public void SaveAsync() {} private void Hidden() {} } }",
4943        );
4944        assert_eq!(
4945            e.purpose.as_deref(),
4946            Some("interface IReader | class Order : IReader; Save")
4947        );
4948    }
4949
4950    #[test]
4951    fn purpose_signature_is_none_when_no_namespace_level_types() {
4952        let e = extract_src("// just a comment\n");
4953        assert!(e.purpose.is_none());
4954    }
4955
4956    #[test]
4957    fn purpose_signature_truncates_at_200_utf16_units() {
4958        let long_bases = (0..40)
4959            .map(|i| format!("IFace{i}"))
4960            .collect::<Vec<_>>()
4961            .join(", ");
4962        let src = format!("public class Wide : {long_bases} {{}}");
4963        let e = extract_src(&src);
4964        let p = e.purpose.expect("purpose present");
4965        assert_eq!(p.encode_utf16().count(), MAX_PURPOSE);
4966        assert!(p.ends_with("..."));
4967    }
4968
4969    // --- inherits via primary_constructor_base_type ------------------------
4970
4971    #[test]
4972    fn primary_constructor_base_type_records_inherits_ref() {
4973        let e = extract_src(
4974            "namespace Fixtures.Misc { public class GadgetBase { public GadgetBase(string name) {} } public record GadgetRecord(string Name) : GadgetBase(Name); }",
4975        );
4976        let inherits: Vec<&str> = e
4977            .refs
4978            .iter()
4979            .filter(|r| r.kind == "inherits")
4980            .map(|r| r.name.as_str())
4981            .collect();
4982        assert!(inherits.contains(&"GadgetBase"));
4983    }
4984
4985    // --- tuple types --------------------------------------------------------
4986
4987    #[test]
4988    fn tuple_return_type_records_each_element_type() {
4989        let e = extract_src(
4990            "namespace Fixtures.Misc { public class GadgetBase {} public class Gadget { public (Gadget Primary, GadgetBase Fallback) Describe() { return (this, null); } } }",
4991        );
4992        let uses_type_names: Vec<&str> = e
4993            .refs
4994            .iter()
4995            .filter(|r| r.kind == "uses-type")
4996            .map(|r| r.name.as_str())
4997            .collect();
4998        assert!(uses_type_names.contains(&"Gadget"));
4999        assert!(uses_type_names.contains(&"GadgetBase"));
5000    }
5001
5002    // --- #if/#elif/#else interrupting a fluent chain -----------------
5003    //
5004    // Native tree-sitter's error recovery for these fixtures swallows the
5005    // directive as a small ERROR-node extra child while continuing the
5006    // interrupted chain as ONE uninterrupted subtree, rather than the clean
5007    // statement split a non-recovering parse would produce (see
5008    // `preproc_promoted_qualifier`'s doc comment for the full mechanism).
5009    // The fixtures live in fixtures/preproc/ and are `include_str!`ed
5010    // here, so this fast unit test and the differential check run on the
5011    // identical bytes -- divergence between them would mean the fixture
5012    // drifted from what this test asserts, not a real behavior change.
5013    // They sit under fixtures/ rather than beside the harness because
5014    // this test is part of the crate and has to compile wherever the crate
5015    // does.
5016    //
5017    // Both fixtures need this much surrounding chain complexity to actually
5018    // reach the ERROR-node recovery path in native tree-sitter -- a much
5019    // shorter chain (e.g. a bare two-call `.Step1() #if DEBUG .DebugStep()
5020    // #endif .Step2()`) was verified to take a completely different, more
5021    // broken error-recovery path instead, so trimming these fixtures down is
5022    // NOT safe without re-verifying against the expected output.
5023
5024    const IF_DIRECTIVE_CHAIN: &str =
5025        include_str!("../fixtures/preproc/preproc_chain_interrupt_if.cs");
5026    const IFELSE_DIRECTIVE_CHAIN: &str =
5027        include_str!("../fixtures/preproc/preproc_chain_interrupt_ifelse.cs");
5028    const NESTED_IF_DIRECTIVE_CHAIN_CONTROL: &str =
5029        include_str!("../fixtures/preproc/preproc_chain_interrupt_nested_control.cs");
5030    const WHOLESTMT_DIRECTIVE_CONTROL: &str =
5031        include_str!("../fixtures/preproc/preproc_chain_wholestmt_control.cs");
5032
5033    fn uses_member_refs(e: &Extraction) -> Vec<(&str, &str, usize)> {
5034        e.refs
5035            .iter()
5036            .filter(|r| r.kind == "uses-member")
5037            .map(|r| (r.name.as_str(), r.member.as_deref().unwrap_or(""), r.line))
5038            .collect()
5039    }
5040
5041    #[test]
5042    fn preproc_if_interrupting_chain_promotes_qualifier_at_the_resumed_identifiers_own_line() {
5043        let e = extract_src(IF_DIRECTIVE_CHAIN);
5044        // Line 31 is `.WriteTo.Debug()` itself -- NOT line 7 where the
5045        // interrupted chain's own `new Pipeline()` starts. Native
5046        // tree-sitter's enclosing (unsplit) node inherits that earlier
5047        // start position, which is exactly the bug this line guards: using
5048        // the wrong node's start position after promotion.
5049        assert!(uses_member_refs(&e).contains(&("WriteTo", "Debug", 31)));
5050    }
5051
5052    #[test]
5053    fn preproc_if_interrupting_chain_places_promoted_ref_after_the_original_chains_own_arguments() {
5054        let e = extract_src(IF_DIRECTIVE_CHAIN);
5055        let refs = uses_member_refs(&e);
5056        let debug_pos = refs
5057            .iter()
5058            .position(|r| *r == ("WriteTo", "Debug", 31))
5059            .expect("promoted ref present");
5060        let interval_day_pos = refs
5061            .iter()
5062            .position(|r| *r == ("Interval", "Day", 26))
5063            .expect("Interval/Day ref present");
5064        let minimum_level_pos = refs
5065            .iter()
5066            .position(|r| *r == ("Level", "Information", 33))
5067            .expect("first MinimumLevel.Override argument ref present");
5068        // A cleanly-split tree walks the interrupted chain's OWN call
5069        // arguments (Interval.Day, part of the pre-`#if` `.File(...)` call)
5070        // before it ever reaches the promoted ref, because that ref lives in
5071        // what a clean parse treats as a separate, later statement.
5072        // Native tree-sitter has no such split -- without the deferred
5073        // (post-recursion) push in the walk's member_access_expression arm,
5074        // the promoted ref lands far too early in this array instead.
5075        assert!(
5076            interval_day_pos < debug_pos,
5077            "Interval/Day ({interval_day_pos}) should precede WriteTo/Debug ({debug_pos})"
5078        );
5079        assert!(debug_pos < minimum_level_pos, "WriteTo/Debug ({debug_pos}) should precede the first MinimumLevel.Override argument ref ({minimum_level_pos})");
5080    }
5081
5082    #[test]
5083    fn preproc_ifelse_interrupting_chain_promotes_qualifier_in_both_arms_but_not_across_endif() {
5084        let e = extract_src(IFELSE_DIRECTIVE_CHAIN);
5085        let refs = uses_member_refs(&e);
5086        // Both the `#if` arm (line 23) and the `#else` arm (line 25) are
5087        // opening directives and each promotes its own qualifier.
5088        assert!(refs.contains(&("WriteTo", "Trace", 23)));
5089        assert!(refs.contains(&("WriteTo", "Console", 25)));
5090        // `#endif` never promotes: a clean parse absorbs it as a
5091        // trailing token rather than splitting the statement there, so
5092        // there is no "MinimumLevel"/"Override" candidate on either side.
5093        assert!(!refs.iter().any(|(_, member, _)| *member == "Override"));
5094        assert!(!refs.iter().any(|(name, _, _)| *name == "MinimumLevel"));
5095    }
5096
5097    #[test]
5098    fn preproc_promotion_does_not_fire_on_nested_if_in_if_chain_interrupt() {
5099        // A `#if` nested inside another `#if` interrupting the same kind of
5100        // chain already parses cleanly WITHOUT the
5101        // compensation (native tree-sitter happens to build a proper
5102        // preproc_if node here too) -- this guards against the
5103        // compensation over-firing on this shape and introducing a spurious
5104        // ref.
5105        let e = extract_src(NESTED_IF_DIRECTIVE_CHAIN_CONTROL);
5106        let refs = uses_member_refs(&e);
5107        assert!(refs.contains(&("WriteTo", "Trace", 25)));
5108        assert!(!refs
5109            .iter()
5110            .any(|(name, member, _)| *name == "WriteTo" && *member == "Debug"));
5111    }
5112
5113    #[test]
5114    fn preproc_promotion_does_not_fire_on_whole_statement_if_guard() {
5115        // An ordinary statement-level `#if DEBUG { ... }` (not interrupting
5116        // an expression) already parses cleanly -- guards against the
5117        // compensation over-firing there too. Both
5118        // calls resolve as ordinary `registry.Attach(...)` member accesses,
5119        // untouched by the directive between them.
5120        let e = extract_src(WHOLESTMT_DIRECTIVE_CONTROL);
5121        let refs = uses_member_refs(&e);
5122        assert_eq!(
5123            refs,
5124            vec![("registry", "Attach", 15), ("registry", "Attach", 17)]
5125        );
5126    }
5127
5128    // --- TS/JS purposes (acceptance cases) -------
5129    //
5130    // Every fixture under fixtures/ts-grammar/ against its expected-output
5131    // string, byte-exact. These are the acceptance cases for TS/JS purpose
5132    // extraction: a mismatch means the extractor changed behaviour, so fix
5133    // the extractor rather than the fixture or the expected string.
5134
5135    use crate::parse::TsGrammar;
5136
5137    fn ts_purpose(src: &str, grammar: TsGrammar) -> Option<String> {
5138        extract_ts_purpose(src, grammar)
5139    }
5140
5141    const ARTICLE_CARD_TSX: &str = include_str!("../fixtures/ts-grammar/ArticleCard.tsx");
5142    const USE_WIDGET_DATA_TS: &str = include_str!("../fixtures/ts-grammar/useWidgetData.ts");
5143    const ORDER_SERVICE_TS: &str = include_str!("../fixtures/ts-grammar/OrderService.ts");
5144    const WIDGETS_SLICE_TS: &str = include_str!("../fixtures/ts-grammar/widgetsSlice.ts");
5145    const STRING_UTILS_TS: &str = include_str!("../fixtures/ts-grammar/stringUtils.ts");
5146    const ARTICLE_TYPES_TS: &str = include_str!("../fixtures/ts-grammar/articleTypes.ts");
5147    const FORMAT_CURRENCY_TS: &str = include_str!("../fixtures/ts-grammar/formatCurrency.ts");
5148    const NOTIFICATION_DIGEST_TS: &str =
5149        include_str!("../fixtures/ts-grammar/notificationDigest.ts");
5150    const PATH_HELPERS_JS: &str = include_str!("../fixtures/ts-grammar/pathHelpers.js");
5151    // New fixtures.
5152    const REEXPORT_BARREL_TS: &str = include_str!("../fixtures/ts-grammar/reexportBarrel.ts");
5153    const WIDGET_PANEL_WITH_NOTE_TS: &str =
5154        include_str!("../fixtures/ts-grammar/widgetPanelWithNote.ts");
5155
5156    #[test]
5157    fn ts_grammar_fixture_article_card_tsx() {
5158        assert_eq!(
5159            ts_purpose(ARTICLE_CARD_TSX, TsGrammar::Tsx).as_deref(),
5160            Some("function ArticleCard | interface ArticleCardProps")
5161        );
5162    }
5163
5164    #[test]
5165    fn ts_grammar_fixture_use_widget_data_ts() {
5166        assert_eq!(
5167            ts_purpose(USE_WIDGET_DATA_TS, TsGrammar::Typescript).as_deref(),
5168            Some("function useWidgetData")
5169        );
5170    }
5171
5172    #[test]
5173    fn ts_grammar_fixture_order_service_ts() {
5174        assert_eq!(
5175            ts_purpose(ORDER_SERVICE_TS, TsGrammar::Typescript).as_deref(),
5176            Some("class OrderService : IOrderService; getOrder, cancelOrder | interface IOrderService")
5177        );
5178    }
5179
5180    #[test]
5181    fn ts_grammar_fixture_widgets_slice_ts() {
5182        assert_eq!(
5183            ts_purpose(WIDGETS_SLICE_TS, TsGrammar::Typescript).as_deref(),
5184            Some("const widgetsSlice | const setLoading | const setItems | default (anonymous)")
5185        );
5186    }
5187
5188    #[test]
5189    fn ts_grammar_fixture_string_utils_ts() {
5190        assert_eq!(
5191            ts_purpose(STRING_UTILS_TS, TsGrammar::Typescript).as_deref(),
5192            Some("function truncate | function slugify | const DEFAULT_MAX_LENGTH")
5193        );
5194    }
5195
5196    #[test]
5197    fn ts_grammar_fixture_article_types_ts() {
5198        assert_eq!(
5199            ts_purpose(ARTICLE_TYPES_TS, TsGrammar::Typescript).as_deref(),
5200            Some("interface ArticleItem | interface ArticleAuthor | type ArticleItemStatus | type ArticlePage")
5201        );
5202    }
5203
5204    #[test]
5205    fn ts_grammar_fixture_format_currency_ts() {
5206        assert_eq!(
5207            ts_purpose(FORMAT_CURRENCY_TS, TsGrammar::Typescript).as_deref(),
5208            Some("default (anonymous)")
5209        );
5210    }
5211
5212    #[test]
5213    fn ts_grammar_fixture_notification_digest_ts() {
5214        assert_eq!(
5215            ts_purpose(NOTIFICATION_DIGEST_TS, TsGrammar::Typescript).as_deref(),
5216            Some("class NotificationDigestBuilder; addEvent, build | function buildDailyDigest")
5217        );
5218    }
5219
5220    #[test]
5221    fn ts_grammar_fixture_path_helpers_js() {
5222        assert_eq!(
5223            ts_purpose(PATH_HELPERS_JS, TsGrammar::Javascript).as_deref(),
5224            Some("function joinSafe | function isAbsolute")
5225        );
5226    }
5227
5228    // --- reexportBarrel.ts fixture + edge cases -----------
5229
5230    #[test]
5231    fn ts_grammar_fixture_reexport_barrel_ts() {
5232        assert_eq!(
5233            ts_purpose(REEXPORT_BARREL_TS, TsGrammar::Typescript).as_deref(),
5234            Some("reexports WidgetCard, WidgetList, Dialog, *, WidgetTypes")
5235        );
5236    }
5237
5238    #[test]
5239    fn reexport_bucket_mixes_with_a_real_export_and_lands_last() {
5240        let src = "export function real() {}\nexport { A, B } from './m';\n";
5241        assert_eq!(
5242            ts_purpose(src, TsGrammar::Typescript).as_deref(),
5243            Some("function real | reexports A, B")
5244        );
5245    }
5246
5247    #[test]
5248    fn local_export_clause_without_from_is_still_skipped() {
5249        let src = "const A = 1;\nexport { A };\n";
5250        assert_eq!(ts_purpose(src, TsGrammar::Typescript), None);
5251    }
5252
5253    #[test]
5254    fn bare_star_reexports_dedupe_to_one_token_regardless_of_statement_count() {
5255        let src = "export * from './a';\nexport * from './b';\n";
5256        assert_eq!(
5257            ts_purpose(src, TsGrammar::Typescript).as_deref(),
5258            Some("reexports *")
5259        );
5260    }
5261
5262    // --- Leading-comment hybrid: widgetPanelWithNote.ts fixture (pure vs hybrid) ---
5263    //
5264    // The pure entry point (`extract_ts_purpose`, exercised via `ts_purpose`
5265    // above) never sees the heuristic -- it must stay AST-only even for a
5266    // fixture whose first line IS a comment. `extract_ts_purpose_with_heuristic`
5267    // (mapcmd.rs's real per-file dispatch and run_extract_dump's dump path)
5268    // is what actually applies the comment prefix -- tested separately below
5269    // via a real temp-dir root/rel pair.
5270
5271    #[test]
5272    fn ts_grammar_fixture_widget_panel_with_note_pure_extractor_stays_ast_only() {
5273        assert_eq!(
5274            ts_purpose(WIDGET_PANEL_WITH_NOTE_TS, TsGrammar::Typescript).as_deref(),
5275            Some("function WidgetPanel | interface WidgetPanelProps")
5276        );
5277    }
5278
5279    fn scratch_root(label: &str) -> std::path::PathBuf {
5280        let nanos = std::time::SystemTime::now()
5281            .duration_since(std::time::UNIX_EPOCH)
5282            .unwrap()
5283            .as_nanos();
5284        let dir = std::env::temp_dir().join(format!(
5285            "scout-extract-hybrid-test-{label}-{}-{nanos}",
5286            std::process::id()
5287        ));
5288        fs::create_dir_all(&dir).expect("create scratch dir");
5289        dir
5290    }
5291
5292    #[test]
5293    fn extract_ts_purpose_with_heuristic_prefixes_a_leading_comment() {
5294        let root = scratch_root("comment");
5295        fs::write(root.join("widget.ts"), WIDGET_PANEL_WITH_NOTE_TS).unwrap();
5296        let purpose = extract_ts_purpose_with_heuristic(
5297            &root,
5298            "widget.ts",
5299            WIDGET_PANEL_WITH_NOTE_TS,
5300            TsGrammar::Typescript,
5301        );
5302        assert_eq!(
5303            purpose.as_deref(),
5304            Some("Renders the widget summary panel and its retry action — function WidgetPanel | interface WidgetPanelProps")
5305        );
5306        fs::remove_dir_all(&root).ok();
5307    }
5308
5309    #[test]
5310    fn extract_ts_purpose_with_heuristic_leaves_a_code_first_line_unprefixed() {
5311        let root = scratch_root("code-first");
5312        let src = "export function plain() { return 1; }\n";
5313        fs::write(root.join("plain.ts"), src).unwrap();
5314        let purpose =
5315            extract_ts_purpose_with_heuristic(&root, "plain.ts", src, TsGrammar::Typescript);
5316        assert_eq!(purpose.as_deref(), Some("function plain"));
5317        fs::remove_dir_all(&root).ok();
5318    }
5319
5320    #[test]
5321    fn extract_ts_purpose_with_heuristic_returns_none_for_comment_plus_zero_export() {
5322        // Zero-export files have no purpose downstream -- no purpose at all
5323        // comes out of this entry point for them, matching extract_ts_purpose's
5324        // own None contract.
5325        let root = scratch_root("comment-zero-export");
5326        let src = "// just notes, nothing exported\nfunction internalOnly() { return 1; }\n";
5327        fs::write(root.join("notesOnly.ts"), src).unwrap();
5328        let purpose =
5329            extract_ts_purpose_with_heuristic(&root, "notesOnly.ts", src, TsGrammar::Typescript);
5330        assert!(purpose.is_none());
5331        fs::remove_dir_all(&root).ok();
5332    }
5333
5334    // --- degrade paths -------------------------------------------------
5335
5336    #[test]
5337    fn broken_syntax_ts_degrades_to_none_not_a_panic() {
5338        // Genuinely malformed: no recognisable top-level export survives
5339        // error recovery, so compose_ts_purpose yields no entries -> None,
5340        // same signal that routes mapcmd.rs's per-file dispatch to the
5341        // heuristic purpose (broken-syntax TS degrades to the heuristic).
5342        let purpose = ts_purpose(
5343            "export class {{{ not valid typescript at all @@@",
5344            TsGrammar::Typescript,
5345        );
5346        assert!(purpose.is_none());
5347    }
5348
5349    #[test]
5350    fn zero_export_file_yields_none_heuristic_fallback() {
5351        // The zero-export shape follows the established C# convention (no
5352        // namespace-level types -> None -> heuristic fallback) rather than
5353        // inventing new behaviour.
5354        assert!(ts_purpose(
5355            "function internalOnly() { return 1; }\nconst alsoInternal = 2;\n",
5356            TsGrammar::Typescript
5357        )
5358        .is_none());
5359        assert!(ts_purpose("// just a comment, no code\n", TsGrammar::Typescript).is_none());
5360    }
5361
5362    // --- Duplicate-header preproc shape (the UTF-16 parse seam's motivating
5363    // corpus case) ------------------------------------------------------
5364    //
5365    // `#if X <header> { ... #else <header> { ... #endif <shared tail> }`
5366    // duplicates a member's header AND its opening brace across both arms
5367    // while sharing one closing brace, so the text is unbalanced once the
5368    // directives are treated as inert. Both engines' roots come back
5369    // has_error() on it; what they must agree on is the repair, and they do
5370    // only because both are handed the same UTF-16 view of the source (see
5371    // parse::utf16_units). Parsing UTF-8 here instead made native recovery
5372    // read the second arm's header as a local function nested inside the
5373    // first arm's still-open block -- one member scope instead of two, so
5374    // every receiver fact in them cancelled as a redeclaration conflict, and
5375    // every type declared after the shape got swallowed as misread
5376    // statements.
5377    //
5378    // These assertions are the expected output for these exact strings, not a
5379    // description of any Rust-side compensation: there is none left to
5380    // describe.
5381
5382    // Two arms, one shared tail, one nested struct and one nested enum
5383    // after it. At this scale the shared repair keeps the outer type and
5384    // its "+"-nested members intact.
5385    const DUPLICATE_HEADER_HOST_SRC: &str = "
5386namespace Fixtures.Preproc
5387{
5388    public static class WidgetCompiler
5389    {
5390        private static readonly int[] _pool = new int[8];
5391
5392#if WIDGET_V2
5393        private static int[] ComputeOffsets(IWidgetProvider items)
5394        {
5395            var count = items.WidgetCount;
5396#else
5397        private static int[] ComputeOffsets(IReadOnlyList<Widget> items)
5398        {
5399            var count = items.Count;
5400#endif
5401            if (count == 0)
5402                return _pool;
5403            return _pool;
5404        }
5405
5406        private struct SlotInfo
5407        {
5408            public object Payload;
5409        }
5410
5411        [System.Flags]
5412        private enum SlotStatus : byte
5413        {
5414            Empty = 0,
5415            Filled = 1,
5416        }
5417    }
5418}
5419";
5420
5421    #[test]
5422    fn duplicate_header_keeps_nested_type_defs_at_their_real_scope() {
5423        let e = extract_src(DUPLICATE_HEADER_HOST_SRC);
5424
5425        assert!(find_def(&e, "Fixtures.Preproc.WidgetCompiler").is_some());
5426
5427        let slot_info = find_def(&e, "Fixtures.Preproc.WidgetCompiler+SlotInfo")
5428            .expect("SlotInfo kept, nested under its type");
5429        assert_eq!(slot_info.kind, "struct");
5430        assert_eq!(slot_info.namespace, "Fixtures.Preproc");
5431
5432        let slot_status =
5433            find_def(&e, "Fixtures.Preproc.WidgetCompiler+SlotStatus").expect("SlotStatus kept");
5434        assert_eq!(slot_status.kind, "enum");
5435        assert!(find_def(&e, "Fixtures.Preproc.WidgetCompiler+SlotStatus.Empty").is_some());
5436        assert!(find_def(&e, "Fixtures.Preproc.WidgetCompiler+SlotStatus.Filled").is_some());
5437
5438        // Both arms' parameter types are recorded; neither arm's refs are lost.
5439        assert!(e
5440            .refs
5441            .iter()
5442            .any(|r| r.kind == "uses-type" && r.name == "IWidgetProvider"));
5443        assert!(e
5444            .refs
5445            .iter()
5446            .any(|r| r.kind == "uses-type" && r.name == "IReadOnlyList"));
5447        assert!(e
5448            .refs
5449            .iter()
5450            .any(|r| r.kind == "uses-type" && r.name == "Widget"));
5451    }
5452
5453    #[test]
5454    fn defs_and_refs_stay_in_ascending_line_order_on_a_pathological_file() {
5455        // Document order is the artifact contract; a file whose root is
5456        // has_error() is where it is easiest to lose, so it is pinned there.
5457        let e = extract_src(DUPLICATE_HEADER_HOST_SRC);
5458        let def_lines: Vec<usize> = e.defs.iter().map(|d| d.line).collect();
5459        let mut sorted = def_lines.clone();
5460        sorted.sort();
5461        assert_eq!(
5462            def_lines, sorted,
5463            "defs must already be in ascending line order"
5464        );
5465
5466        let ref_lines: Vec<usize> = e.refs.iter().map(|r| r.line).collect();
5467        let mut sorted_refs = ref_lines.clone();
5468        sorted_refs.sort();
5469        assert_eq!(
5470            ref_lines, sorted_refs,
5471            "refs must already be in ascending line order"
5472        );
5473    }
5474
5475    #[test]
5476    fn well_formed_file_is_unaffected() {
5477        let e = extract_src(
5478            "namespace Fixtures.Widgets { public class Gadget { public int Count; } public struct Handle {} }",
5479        );
5480        assert!(find_def(&e, "Fixtures.Widgets.Gadget").is_some());
5481        assert!(find_def(&e, "Fixtures.Widgets.Handle").is_some());
5482    }
5483
5484    // Same shape, plus a SECOND nested type whose own body repeats the
5485    // pattern. Two of them in one file is enough accumulated damage that the
5486    // shared repair discards BOTH wrapping types: no WidgetCompiler def, no
5487    // Emitter def, and everything that survives lands flat at namespace-less
5488    // ambient scope. This drop is the correct output -- it is the shape the
5489    // removed Rust-only recovery pass used to "rescue", inventing an `Emitter`
5490    // def that must not appear.
5491    const DUPLICATE_HEADER_HOST_WITH_NESTED_EMITTER_SRC: &str = "
5492namespace Fixtures.Preproc
5493{
5494    public static class WidgetCompiler
5495    {
5496        private static readonly int[] _pool = new int[8];
5497
5498#if WIDGET_V2
5499        private static int[] ComputeOffsets(IWidgetProvider items)
5500        {
5501            var count = items.WidgetCount;
5502#else
5503        private static int[] ComputeOffsets(IReadOnlyList<Widget> items)
5504        {
5505            var count = items.Count;
5506#endif
5507            if (count == 0)
5508                return _pool;
5509            return _pool;
5510        }
5511
5512        private struct SlotInfo
5513        {
5514            public object Payload;
5515        }
5516
5517        [System.Flags]
5518        private enum SlotStatus : byte
5519        {
5520            Empty = 0,
5521            Filled = 1,
5522        }
5523
5524        private static class Emitter
5525        {
5526#if WIDGET_V2
5527            public static bool TryEmit(Widget expr, IWidgetProvider items, Sink sink)
5528            {
5529                var scaled = items.WidgetCount;
5530#else
5531            public static bool TryEmit(Widget expr, IReadOnlyList<Widget> items, Sink sink)
5532            {
5533                var scaled = items.Count;
5534#endif
5535                sink.Write(scaled);
5536                return true;
5537            }
5538        }
5539    }
5540}
5541";
5542
5543    #[test]
5544    fn stacked_duplicate_headers_drop_both_wrapping_types_like_the_reference() {
5545        let e = extract_src(DUPLICATE_HEADER_HOST_WITH_NESTED_EMITTER_SRC);
5546
5547        assert!(e.defs.iter().all(|d| d.name != "WidgetCompiler"));
5548        assert!(e.defs.iter().all(|d| d.name != "Emitter"));
5549
5550        let slot_info = find_def(&e, "SlotInfo").expect("SlotInfo survives, flat");
5551        assert_eq!(slot_info.kind, "struct");
5552        assert_eq!(slot_info.namespace, "");
5553        assert!(find_def(&e, "SlotStatus").is_some());
5554        assert!(find_def(&e, "SlotStatus.Empty").is_some());
5555        assert!(find_def(&e, "SlotStatus.Filled").is_some());
5556
5557        // The refs inside the innermost body still come through, at that
5558        // same flat ambient scope.
5559        assert!(e
5560            .refs
5561            .iter()
5562            .any(|r| r.kind == "uses-type" && r.name == "Sink"));
5563        assert!(e.refs.iter().any(|r| r.kind == "uses-member"
5564            && r.name == "sink"
5565            && r.member.as_deref() == Some("Write")));
5566    }
5567
5568    // --- Def member facts -------------------------------------------------------
5569
5570    fn member_facts(e: &Extraction) -> Vec<(&str, Option<&str>)> {
5571        e.refs
5572            .iter()
5573            .filter(|r| r.kind == "uses-member")
5574            .map(|r| {
5575                (
5576                    r.member.as_deref().unwrap_or(""),
5577                    r.receiver_type.as_deref(),
5578                )
5579            })
5580            .collect()
5581    }
5582
5583    #[test]
5584    fn stage2a_def_records_properties_fields_and_method_returns() {
5585        let e = extract_src(
5586            r#"
5587namespace App.Facts;
5588
5589public class Widget
5590{
5591  private readonly ILogger _log;
5592  private int a, b;
5593  public const int Max = 3;
5594  public static string Prefix { get; } = "p";
5595  public string Name => "n";
5596  public int this[int i] => i;
5597  public event System.EventHandler Changed;
5598
5599  public Task<Foo> GetAsync() => null;
5600  public void Nothing() { }
5601  public Some.Ns.Thing Qualified() => null;
5602  public int Num() => 1;
5603  private Hidden Secret() => null;
5604}
5605"#,
5606        );
5607        let d = find_def(&e, "App.Facts.Widget").expect("Widget def present");
5608        assert_eq!(
5609            d.properties,
5610            vec!["Prefix", "Name"],
5611            "source order; the indexer is not a property"
5612        );
5613        assert_eq!(
5614            d.fields,
5615            vec!["_log", "a", "b", "Max"],
5616            "every declarator of every field, incl. const; the event is not a field"
5617        );
5618        assert_eq!(
5619            d.method_returns,
5620            vec![("GetAsync".to_string(), "Task".to_string()), ("Qualified".to_string(), "Thing".to_string())],
5621            "generic args stripped to the base identifier, a qualified return reduced to its last segment, void/int omitted, FIRST-declaration order"
5622        );
5623        assert!(
5624            !d.methods.contains(&"Secret".to_string()),
5625            "methodReturns stays parallel to methods: a private method is in neither"
5626        );
5627        assert!(d.method_returns.iter().all(|(n, _)| n != "Secret"));
5628    }
5629
5630    #[test]
5631    fn stage2a_type_declaring_none_of_the_new_members_records_all_three_empty() {
5632        let e = extract_src("namespace App.Bare { public class Empty { public void Go() { } } }");
5633        let d = find_def(&e, "App.Bare.Empty").expect("Empty def present");
5634        assert_eq!(d.methods, vec!["Go"]);
5635        assert!(d.properties.is_empty());
5636        assert!(d.fields.is_empty());
5637        assert!(
5638            d.method_returns.is_empty(),
5639            "empty means OMITTED at serialization -- pre-stage-2 bytes preserved"
5640        );
5641    }
5642
5643    #[test]
5644    fn stage2a_method_returns_keeps_the_first_overload_and_never_backfills_a_void_first_declaration(
5645    ) {
5646        let e = extract_src(
5647            r#"
5648namespace App.Overloads;
5649
5650public class Api
5651{
5652  public Foo Get(int id) => null;
5653  public Bar Get(string key) => null;
5654  public void Send(int id) { }
5655  public Receipt Send(string key) => null;
5656}
5657"#,
5658        );
5659        let d = find_def(&e, "App.Overloads.Api").expect("Api def present");
5660        assert_eq!(
5661            d.method_returns,
5662            vec![("Get".to_string(), "Foo".to_string())],
5663            "Get keeps the first overload; Send is blocked by its void first declaration"
5664        );
5665    }
5666
5667    #[test]
5668    fn stage2a_interface_records_properties_and_returns_without_any_public_modifier() {
5669        let e = extract_src(
5670            r#"
5671namespace App.Contracts;
5672
5673public interface IRepo
5674{
5675  string Name { get; }
5676  Widget Find(int id);
5677}
5678"#,
5679        );
5680        let d = find_def(&e, "App.Contracts.IRepo").expect("IRepo def present");
5681        assert_eq!(d.properties, vec!["Name"]);
5682        assert_eq!(
5683            d.method_returns,
5684            vec![("Find".to_string(), "Widget".to_string())]
5685        );
5686        assert!(d.fields.is_empty());
5687    }
5688
5689    // --- Receiver facts ---------------------------------------
5690
5691    #[test]
5692    fn stage2b_facts_come_from_locals_var_new_params_fields_and_primary_ctor_params() {
5693        let e = extract_src(
5694            r#"
5695namespace App.Receivers;
5696
5697public class Host(IClock clock)
5698{
5699  private readonly ILogger _log;
5700
5701  public void Run(IRepo repo)
5702  {
5703    Helper h = new Helper();
5704    var svc = new Service();
5705    repo.Save();
5706    h.Help();
5707    svc.Go();
5708    _log.Info();
5709    clock.Now();
5710  }
5711}
5712"#,
5713        );
5714        assert_eq!(
5715            member_facts(&e),
5716            vec![
5717                ("Save", Some("IRepo")),
5718                ("Help", Some("Helper")),
5719                ("Go", Some("Service")),
5720                ("Info", Some("ILogger")),
5721                ("Now", Some("IClock")),
5722            ]
5723        );
5724    }
5725
5726    #[test]
5727    fn stage2b_receiver_type_is_set_only_on_refs_that_earned_a_fact() {
5728        let e = extract_src(
5729            r#"
5730namespace App.Shape;
5731
5732public class Host
5733{
5734  public void Run(IRepo repo) { repo.Save(); }
5735}
5736"#,
5737        );
5738        let r = e
5739            .refs
5740            .iter()
5741            .find(|r| r.kind == "uses-member")
5742            .expect("member ref present");
5743        assert_eq!(r.name, "repo");
5744        assert_eq!(r.qualified, None);
5745        assert!(!r.generic);
5746        assert_eq!(r.receiver_type.as_deref(), Some("IRepo"));
5747    }
5748
5749    #[test]
5750    fn stage2b_no_fact_for_var_from_call_predefined_types_or_an_implicit_lambda_parameter() {
5751        let e = extract_src(
5752            r#"
5753namespace App.NoFacts;
5754
5755public class Host
5756{
5757  public void Run(string text, System.Collections.Generic.List<Widget> items)
5758  {
5759    var computed = Compute();
5760    int count = 0;
5761    computed.Use();
5762    text.Trim();
5763    count.ToString();
5764    items.ForEach(x => x.Do());
5765  }
5766}
5767"#,
5768        );
5769        let facts: Vec<(String, Option<&str>)> = e
5770            .refs
5771            .iter()
5772            .filter(|r| r.kind == "uses-member")
5773            .map(|r| {
5774                (
5775                    format!("{}.{}", r.name, r.member.as_deref().unwrap_or("")),
5776                    r.receiver_type.as_deref(),
5777                )
5778            })
5779            .collect();
5780        assert_eq!(
5781            facts,
5782            vec![
5783                // var + a call initializer: resolve-time territory, never guessed here
5784                ("computed.Use".to_string(), None),
5785                // predefined types: no fact
5786                ("text.Trim".to_string(), None),
5787                ("count.ToString".to_string(), None),
5788                // an explicitly typed parameter DOES earn a fact even when its
5789                // declared type is qualified AND generic -- reduced to "List"
5790                ("items.ForEach".to_string(), Some("List")),
5791                // implicit lambda parameter: no type node, no fact
5792                ("x.Do".to_string(), None),
5793            ]
5794        );
5795    }
5796
5797    #[test]
5798    fn stage2b_two_declarations_of_one_local_with_different_types_cancel_to_no_fact() {
5799        let e = extract_src(
5800            r#"
5801namespace App.Conflict;
5802
5803public class Host
5804{
5805  public void Run(bool flag)
5806  {
5807    if (flag) { Widget x = new Widget(); x.Go(); }
5808    else { Gadget x = new Gadget(); x.Go(); }
5809  }
5810}
5811"#,
5812        );
5813        assert_eq!(
5814            member_facts(&e),
5815            vec![("Go", None), ("Go", None)],
5816            "the method-level flat table is conflicted for \"x\""
5817        );
5818    }
5819
5820    #[test]
5821    fn stage2b_same_local_name_declared_twice_with_the_same_type_still_yields_the_fact() {
5822        let e = extract_src(
5823            r#"
5824namespace App.NoConflict;
5825
5826public class Host
5827{
5828  public void Run(bool flag)
5829  {
5830    if (flag) { Widget x = new Widget(); x.Go(); }
5831    else { Widget x = new Widget(); x.Go(); }
5832  }
5833}
5834"#,
5835        );
5836        assert_eq!(
5837            member_facts(&e),
5838            vec![("Go", Some("Widget")), ("Go", Some("Widget"))]
5839        );
5840    }
5841
5842    #[test]
5843    fn stage2b_a_parameter_shadows_a_same_named_field_of_a_different_type() {
5844        let e = extract_src(
5845            r#"
5846namespace App.Shadow;
5847
5848public class Host
5849{
5850  private Widget handler;
5851
5852  public void Run(Gadget handler)
5853  {
5854    handler.Go();
5855  }
5856
5857  public void Untouched()
5858  {
5859    handler.Go();
5860  }
5861}
5862"#,
5863        );
5864        assert_eq!(
5865            member_facts(&e),
5866            vec![("Go", Some("Gadget")), ("Go", Some("Widget"))],
5867            "inside Run the parameter wins; the other method still sees the field"
5868        );
5869    }
5870
5871    #[test]
5872    fn stage2b_a_nested_type_never_inherits_the_enclosing_types_field_table() {
5873        let e = extract_src(
5874            r#"
5875namespace App.Nested;
5876
5877public class Outer
5878{
5879  private Widget handler;
5880
5881  public class Inner
5882  {
5883    public void Run() { handler.Go(); }
5884  }
5885}
5886"#,
5887        );
5888        assert_eq!(
5889            member_facts(&e),
5890            vec![("Go", None)],
5891            "a nested type cannot reach an outer instance field"
5892        );
5893    }
5894
5895    #[test]
5896    fn stage2b_a_flattened_chain_tail_never_inherits_the_heads_receiver_type() {
5897        // Stage-1 chain-tail regression class, extended for receiverType: a
5898        // tail must NOT inherit a fact it did not earn. Structurally
5899        // impossible here because the tail's qualifier is dotted.
5900        let e = extract_src(
5901            r#"
5902namespace App.Consumers;
5903
5904public class Chain
5905{
5906  public void Run()
5907  {
5908    Widget w = new Widget();
5909    w.Inner.Tail();
5910  }
5911}
5912"#,
5913        );
5914        let head = e
5915            .refs
5916            .iter()
5917            .find(|r| r.member.as_deref() == Some("Inner"))
5918            .expect("head ref present");
5919        let tail = e
5920            .refs
5921            .iter()
5922            .find(|r| r.member.as_deref() == Some("Tail"))
5923            .expect("tail ref present");
5924        assert_eq!(
5925            head.receiver_type.as_deref(),
5926            Some("Widget"),
5927            "the head is the ref the local declaration vouches for"
5928        );
5929        assert_eq!(
5930            tail.qualified.as_deref(),
5931            Some("w.Inner"),
5932            "the tail's qualifier is the flattened chain window"
5933        );
5934        assert_eq!(
5935            tail.receiver_type, None,
5936            "the tail must NOT inherit the head's receiverType"
5937        );
5938    }
5939
5940    #[test]
5941    fn stage2b_a_generic_qualifier_never_carries_a_receiver_fact() {
5942        // A type-argument list is syntax no local, parameter or field can
5943        // carry, so the bare-only guard refuses the lookup outright even when
5944        // a same-named local happens to exist.
5945        let e = extract_src(
5946            r#"
5947namespace App.Generic;
5948
5949public class Host
5950{
5951  public void Run()
5952  {
5953    Widget Cache = new Widget();
5954    Cache<int>.Go();
5955  }
5956}
5957"#,
5958        );
5959        let r = e
5960            .refs
5961            .iter()
5962            .find(|r| r.member.as_deref() == Some("Go"))
5963            .expect("member ref present");
5964        assert!(r.generic);
5965        assert_eq!(r.receiver_type, None);
5966    }
5967
5968    #[test]
5969    fn stage2b_a_local_declared_in_a_using_or_for_statement_is_a_fact() {
5970        let e = extract_src(
5971            r#"
5972namespace App.Statements;
5973
5974public class Host
5975{
5976  public void Run()
5977  {
5978    using (Stream s = Open()) { s.Read(); }
5979    for (Counter c = Start(); ; ) { c.Tick(); }
5980  }
5981}
5982"#,
5983        );
5984        assert_eq!(
5985            member_facts(&e),
5986            vec![("Read", Some("Stream")), ("Tick", Some("Counter"))]
5987        );
5988    }
5989
5990    #[test]
5991    fn stage2b_an_explicitly_typed_foreach_variable_is_a_fact() {
5992        // foreach_statement carries its own `type`/`left` fields,
5993        // not a variable_declaration node, so it needs its own rule rather
5994        // than falling out of the ordinary declaration walk for free. An
5995        // explicitly typed loop variable is answered by that type node
5996        // exactly like any other declaration.
5997        let e = extract_src(
5998            r#"
5999namespace App.ForEach;
6000
6001public class Host
6002{
6003  public void Run(System.Collections.Generic.List<Widget> items)
6004  {
6005    foreach (Widget w in items) { w.Go(); }
6006  }
6007}
6008"#,
6009        );
6010        let go = e
6011            .refs
6012            .iter()
6013            .find(|r| r.member.as_deref() == Some("Go"))
6014            .expect("member ref present");
6015        assert_eq!(go.receiver_type.as_deref(), Some("Widget"));
6016    }
6017
6018    #[test]
6019    fn stage2b_a_local_function_folds_into_the_enclosing_members_flat_table() {
6020        let e = extract_src(
6021            r#"
6022namespace App.LocalFn;
6023
6024public class Host
6025{
6026  public void Run()
6027  {
6028    Widget w = new Widget();
6029    void Inner() { w.Go(); }
6030  }
6031}
6032"#,
6033        );
6034        let go = e
6035            .refs
6036            .iter()
6037            .find(|r| r.member.as_deref() == Some("Go"))
6038            .expect("member ref present");
6039        assert_eq!(
6040            go.receiver_type.as_deref(),
6041            Some("Widget"),
6042            "a local function sees the enclosing method's locals"
6043        );
6044    }
6045
6046    #[test]
6047    fn stage2b_an_explicitly_typed_declaration_never_reads_its_new_initializer() {
6048        let e = extract_src(
6049            r#"
6050namespace App.Explicit;
6051
6052public class Host
6053{
6054  public void Run()
6055  {
6056    object o = new Widget();
6057    o.Go();
6058  }
6059}
6060"#,
6061        );
6062        let go = e
6063            .refs
6064            .iter()
6065            .find(|r| r.member.as_deref() == Some("Go"))
6066            .expect("member ref present");
6067        assert_eq!(
6068            go.receiver_type, None,
6069            "`object` is a predefined type: no fact, and Widget is never substituted for it"
6070        );
6071    }
6072
6073    #[test]
6074    fn stage2b_each_duplicated_header_arm_keeps_its_own_receiver_facts() {
6075        // The duplicate-header preproc shape (see
6076        // DUPLICATE_HEADER_HOST_WITH_NESTED_EMITTER_SRC). Each arm is its own
6077        // method_declaration, so each arm's parameter list vouches for that
6078        // arm's refs -- the two arms are NOT one flat scope in which `items`
6079        // is a conflicting redeclaration. The inner type's two arms lose the
6080        // parameter list along with their own header, so their
6081        // `items` refs get no fact; `sink` does, from the surviving one.
6082        let e = extract_src(DUPLICATE_HEADER_HOST_WITH_NESTED_EMITTER_SRC);
6083        let facts: Vec<(&str, Option<&str>)> = e
6084            .refs
6085            .iter()
6086            .filter(|r| r.kind == "uses-member")
6087            .map(|r| (r.name.as_str(), r.receiver_type.as_deref()))
6088            .collect();
6089        assert_eq!(
6090            facts,
6091            vec![
6092                ("items", Some("IWidgetProvider")),
6093                ("items", Some("IReadOnlyList")),
6094                ("items", None),
6095                ("items", None),
6096                ("sink", Some("Sink")),
6097            ]
6098        );
6099    }
6100
6101    // --- Extension-method def facts ---------------------------------------------
6102
6103    #[test]
6104    fn stage3_extension_methods_recorded_in_source_order_deduped_by_name_this_type_and_arity() {
6105        let e = extract_src(
6106            r#"
6107namespace App.Ext;
6108
6109public static class WidgetExtensions
6110{
6111  public static Widget Copy(this Widget w) => w;
6112  public static void Render(this Widget w) { }
6113  public static void Render(this Widget w, int depth) { }
6114  public static void Render(this Widget w, string label) { }
6115  public static string Trim(this string s) => s;
6116  public static void Each(this System.Collections.Generic.List<Widget> items) { }
6117  public static void Plain(Widget w) { }
6118  public static void Late(Widget w, this Gadget g) { }
6119  internal static void Hidden(this Gadget g) { }
6120}
6121"#,
6122        );
6123        let d = find_def(&e, "App.Ext.WidgetExtensions").expect("WidgetExtensions def present");
6124        let pairs: Vec<(&str, &str, usize, i64)> = d
6125            .extension_methods
6126            .iter()
6127            .map(|x| {
6128                (
6129                    x.name.as_str(),
6130                    x.this_type.as_str(),
6131                    x.arity_min,
6132                    x.arity_max,
6133                )
6134            })
6135            .collect();
6136        assert_eq!(
6137            pairs,
6138            vec![
6139                ("Copy", "Widget", 0, 0),
6140                // The two 1-parameter Render overloads collapse into ONE entry
6141                // -- the dedup key is the (name, thisType, arityMin, arityMax)
6142                // QUADRUPLE, and both name the same call shape. The 0-parameter
6143                // overload is a DIFFERENT entry; before the first tighten
6144                // amendment all three were one arity-blind entry any Render(...)
6145                // call could match.
6146                ("Render", "Widget", 0, 0),
6147                ("Render", "Widget", 1, 1),
6148                // predefined this-types are KEPT, unlike stage-2 receiver facts
6149                ("Trim", "string", 0, 0),
6150                // generic args stripped to the base identifier, qualified name
6151                // reduced to its last segment
6152                ("Each", "List", 0, 0),
6153                // no accessibility filter: internal extensions count
6154                ("Hidden", "Gadget", 0, 0),
6155            ]
6156        );
6157        // ...and the generic amendment records the stripped arguments alongside
6158        // the base identifier rather than discarding them.
6159        assert_eq!(
6160            d.extension_methods
6161                .iter()
6162                .map(|x| x.this_args.clone())
6163                .collect::<Vec<_>>(),
6164            vec![
6165                None,
6166                None,
6167                None,
6168                None,
6169                Some(vec!["Widget".to_string()]),
6170                None
6171            ],
6172            "thisArgs is present ONLY on the generic this-parameter"
6173        );
6174        assert!(
6175            !pairs.iter().any(|(n, ..)| *n == "Plain"),
6176            "a static method with no this-parameter is not an extension method"
6177        );
6178        assert!(
6179            !pairs.iter().any(|(n, ..)| *n == "Late"),
6180            "`this` on a NON-first parameter is not an extension method"
6181        );
6182        // The accessibility asymmetry is deliberate and worth pinning:
6183        // `methods` stays public-only, so `Hidden` is reachable ONLY through
6184        // the extension tier.
6185        assert!(!d.methods.contains(&"Hidden".to_string()));
6186    }
6187
6188    // --- The ref-side `argCount` fact ------------------------------------------
6189
6190    // (qualified-or-bare qualifier + "." + member, argCount) for every
6191    // uses-member ref, in extraction order.
6192    fn member_arg_counts(e: &Extraction) -> Vec<(String, Option<usize>)> {
6193        e.refs
6194            .iter()
6195            .filter(|r| r.kind == "uses-member")
6196            .map(|r| {
6197                let qualifier = r.qualified.as_deref().unwrap_or(r.name.as_str());
6198                (
6199                    format!("{qualifier}.{}", r.member.as_deref().unwrap_or("")),
6200                    r.arg_count,
6201                )
6202            })
6203            .collect()
6204    }
6205
6206    #[test]
6207    fn stage3_arg_count_is_recorded_only_for_a_call_and_always_from_the_refs_own_invocation() {
6208        let e = extract_src(
6209            r#"
6210namespace App.Consumers;
6211
6212public class Chain
6213{
6214  public void Run()
6215  {
6216    Widget w = new Widget();
6217    w.Inner.Tail(1, 2);
6218    Send(w.Payload);
6219    Send(w.Compute(7));
6220    var s = w.Slug;
6221  }
6222}
6223"#,
6224        );
6225        assert_eq!(
6226            member_arg_counts(&e),
6227            vec![
6228                // The flattened chain TAIL answers for ITS OWN call, not for
6229                // anything the head is part of -- the same independence stage 2
6230                // gave receiverType, now for the second borrowed-fact hazard.
6231                ("w.Inner.Tail".to_string(), Some(2)),
6232                // The chain HEAD is the qualifier of ".Tail", never a callee.
6233                ("w.Inner".to_string(), None),
6234                // A member access sitting in someone else's ARGUMENT list has an
6235                // invocation_expression above it too. It must NOT inherit that
6236                // call's count -- the guard is "this node IS the function
6237                // field", not "an invocation is somewhere overhead".
6238                ("w.Payload".to_string(), None),
6239                ("w.Compute".to_string(), Some(1)),
6240                // An ordinary property read: no argCount, which is what keeps it
6241                // out of the extension tier entirely.
6242                ("w.Slug".to_string(), None),
6243            ]
6244        );
6245    }
6246
6247    #[test]
6248    fn stage3_a_kg1_promoted_qualifier_computes_arg_count_from_its_own_invocation_parent() {
6249        // The chain-promotion compensation supplies only the QUALIFIER text,
6250        // from a DIFFERENT node than the one being walked. Its argCount must
6251        // still come from the walked node's own invocation parent: the promoted
6252        // `WriteTo.Debug()` call is a zero-argument call, and the surrounding
6253        // `.File(...)` call's seven arguments, which this subtree still
6254        // contains, must not leak into it.
6255        let e = extract_src(IF_DIRECTIVE_CHAIN);
6256        let promoted = e
6257            .refs
6258            .iter()
6259            .find(|r| r.name == "WriteTo" && r.member.as_deref() == Some("Debug"))
6260            .expect("promoted ref present");
6261        assert_eq!(promoted.arg_count, Some(0), "`.Debug()` takes no arguments; the wrapping `.File(...)` call's count must not leak in");
6262        // And every ordinary ref in the same chain answers for itself. `Interval
6263        // .Day` (line 26) sits INSIDE the seven-argument `.File(...)` list and
6264        // is a plain value read, so it records no argCount at all.
6265        assert_eq!(
6266            e.refs
6267                .iter()
6268                .filter(|r| r.kind == "uses-member")
6269                .map(|r| (
6270                    r.name.as_str(),
6271                    r.member.as_deref().unwrap_or(""),
6272                    r.arg_count
6273                ))
6274                .collect::<Vec<_>>(),
6275            vec![
6276                ("e", "Level", None),
6277                ("Level", "Error", None),
6278                ("Interval", "Day", None),
6279                ("WriteTo", "Debug", Some(0)),
6280                ("Level", "Information", None),
6281                ("Level", "Information", None),
6282            ]
6283        );
6284    }
6285
6286    #[test]
6287    fn stage3_a_type_declaring_no_extension_methods_records_an_empty_list() {
6288        let e = extract_src("namespace App.Plain { public static class Utils { public static void Go(Widget w) { } } }");
6289        let d = find_def(&e, "App.Plain.Utils").expect("Utils def present");
6290        assert_eq!(d.methods, vec!["Go"]);
6291        assert!(
6292            d.extension_methods.is_empty(),
6293            "empty means OMITTED at serialization -- pre-stage-3 bytes preserved"
6294        );
6295    }
6296
6297    #[test]
6298    fn stage3_this_type_unwraps_nullable_and_array_the_same_way_a_receiver_fact_does() {
6299        let e = extract_src(
6300            r#"
6301namespace App.Ext;
6302
6303public static class Shapes
6304{
6305  public static void Each(this Widget[] items) { }
6306  public static void Maybe(this Widget? w) { }
6307  public static void Deep(this App.Other.Gadget g) { }
6308}
6309"#,
6310        );
6311        let d = find_def(&e, "App.Ext.Shapes").expect("Shapes def present");
6312        let pairs: Vec<(&str, &str)> = d
6313            .extension_methods
6314            .iter()
6315            .map(|x| (x.name.as_str(), x.this_type.as_str()))
6316            .collect();
6317        assert_eq!(
6318            pairs,
6319            vec![("Each", "Widget"), ("Maybe", "Widget"), ("Deep", "Gadget")]
6320        );
6321    }
6322
6323    #[test]
6324    fn stage3_a_this_parameter_carrying_a_second_modifier_is_still_an_extension_method() {
6325        // `this ref T` has TWO modifier children -- the scan is `.some(...)`,
6326        // not "the first modifier is `this`".
6327        let e = extract_src("namespace App.Ext { public static class R { public static void Bump(this ref Counter c) { } } }");
6328        let d = find_def(&e, "App.Ext.R").expect("R def present");
6329        let pairs: Vec<(&str, &str)> = d
6330            .extension_methods
6331            .iter()
6332            .map(|x| (x.name.as_str(), x.this_type.as_str()))
6333            .collect();
6334        assert_eq!(pairs, vec![("Bump", "Counter")]);
6335    }
6336
6337    #[test]
6338    fn stage3_base_type_identifier_keeps_predefined_only_for_the_this_parameter_caller() {
6339        // Same source, two fact families: `Trim`'s this-type records "string"
6340        // while the method's own predefined RETURN type still yields no
6341        // methodReturns fact -- the keep_predefined flag is per-call-site.
6342        let e = extract_src("namespace App.Ext { public static class S { public static string Trim(this string s) => s; } }");
6343        let d = find_def(&e, "App.Ext.S").expect("S def present");
6344        assert_eq!(
6345            d.extension_methods
6346                .iter()
6347                .map(|x| x.this_type.as_str())
6348                .collect::<Vec<_>>(),
6349            vec!["string"]
6350        );
6351        assert!(
6352            d.method_returns.is_empty(),
6353            "every stage-2 call site passes keep_predefined=false and is unchanged"
6354        );
6355    }
6356
6357    // The `extract-dump` JSON is a byte-exact surface, so its KEY ORDER is
6358    // significant in its own right.
6359    fn def_json_keys(d: &DefRecord) -> Vec<&'static str> {
6360        match def_to_json(d) {
6361            Json::Obj(fields) => fields.into_iter().map(|(k, _)| k).collect(),
6362            _ => panic!("def_to_json must produce an object"),
6363        }
6364    }
6365
6366    #[test]
6367    fn stage3_extension_methods_serialize_last_with_the_arity_range_and_this_args() {
6368        let e = extract_src(
6369            "namespace App.Ext { public static class W { public static Widget Copy(this Widget w) => w; public static string Trim(this string s, int n) => s; public static void Each(this List<Widget> l, params int[] xs) { } } }",
6370        );
6371        let d = find_def(&e, "App.Ext.W").expect("W def present");
6372        assert_eq!(
6373            def_json_keys(d),
6374            vec![
6375                "id",
6376                "name",
6377                "namespace",
6378                "kind",
6379                "line",
6380                "methods",
6381                "methodReturns",
6382                "extensionMethods"
6383            ],
6384            "extensionMethods lands AFTER the stage-2 additions, not among them"
6385        );
6386        let Json::Obj(fields) = def_to_json(d) else {
6387            panic!("object")
6388        };
6389        let (_, ext) = fields
6390            .into_iter()
6391            .find(|(k, _)| *k == "extensionMethods")
6392            .expect("extensionMethods present");
6393        let Json::Arr(entries) = ext else {
6394            panic!("extensionMethods is an array")
6395        };
6396        assert_eq!(entries.len(), 3);
6397        for entry in &entries[..2] {
6398            let Json::Obj(kv) = entry else {
6399                panic!("each entry is an object")
6400            };
6401            assert_eq!(
6402                kv.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
6403                vec!["name", "thisType", "arityMin", "arityMax"],
6404                "entry key order is significant too"
6405            );
6406        }
6407        // The two arity halves are NUMBERS, not strings: a byte-diff would
6408        // catch a quoted one. arityMax is
6409        // SIGNED: -1 is the unbounded-`params` sentinel.
6410        let Json::Obj(kv) = &entries[1] else {
6411            panic!("object")
6412        };
6413        match kv.iter().find(|(k, _)| *k == "arityMin").map(|(_, v)| v) {
6414            Some(Json::Num(n)) => {
6415                assert_eq!(*n, 1, "Trim(this string s, int n) requires one argument")
6416            }
6417            _ => panic!("arityMin must serialize as a JSON number"),
6418        }
6419        match kv.iter().find(|(k, _)| *k == "arityMax").map(|(_, v)| v) {
6420            Some(Json::Int(n)) => assert_eq!(*n, 1),
6421            _ => panic!("arityMax must serialize as a JSON number"),
6422        }
6423        // The generic entry carries thisArgs, LAST, and its params tail makes
6424        // arityMax the -1 sentinel.
6425        let Json::Obj(kv) = &entries[2] else {
6426            panic!("object")
6427        };
6428        assert_eq!(
6429            kv.iter().map(|(k, _)| *k).collect::<Vec<_>>(),
6430            vec!["name", "thisType", "arityMin", "arityMax", "thisArgs"],
6431            "thisArgs lands after arityMax, and only on a generic this-parameter"
6432        );
6433        assert!(
6434            matches!(
6435                kv.iter().find(|(k, _)| *k == "arityMax").map(|(_, v)| v),
6436                Some(Json::Int(-1))
6437            ),
6438            "a params tail serializes arityMax as the number -1"
6439        );
6440    }
6441
6442    #[test]
6443    fn stage3_bases_serializes_after_extension_methods_and_is_absent_when_empty() {
6444        // `Ns.BaseWidget<int>` carries a type-argument list, so this def now
6445        // ALSO gets a baseGenericArgs entry -- bases is no
6446        // longer the last key when a base is itself generic, which is the
6447        // point this fixture was chosen to cover: both keys' relative order
6448        // still holds (bases before baseGenericArgs, both before the absent
6449        // testMethods).
6450        let e = extract_src(
6451            "namespace App.Other { public class Widget : Ns.BaseWidget<int>, IWidget { } }",
6452        );
6453        let d = find_def(&e, "App.Other.Widget").expect("Widget def present");
6454        assert_eq!(
6455            def_json_keys(d),
6456            vec![
6457                "id",
6458                "name",
6459                "namespace",
6460                "kind",
6461                "line",
6462                "methods",
6463                "bases",
6464                "baseGenericArgs"
6465            ],
6466            "bases lands after methods, baseGenericArgs immediately after bases"
6467        );
6468        assert_eq!(
6469            d.bases,
6470            vec!["BaseWidget".to_string(), "IWidget".to_string()],
6471            "base IDENTIFIERS: generic arguments stripped, a qualified name cut to its last segment"
6472        );
6473        assert_eq!(
6474            d.base_generic_args,
6475            vec![("BaseWidget".to_string(), vec!["int".to_string()])],
6476            "BaseWidget<int> is non-generic Widget's closed base -- int is a predefined type, KEPT (this base pass keeps predefined types the same way the this-parameter thisArgs facts do); IWidget carries no type-argument list at all, so it contributes no entry"
6477        );
6478
6479        let plain = extract_src("namespace App.Other { public class Bare { } }");
6480        let pd = find_def(&plain, "App.Other.Bare").expect("Bare def present");
6481        assert_eq!(
6482            def_json_keys(pd),
6483            vec!["id", "name", "namespace", "kind", "line", "methods"]
6484        );
6485    }
6486
6487    #[test]
6488    fn stage3_extension_methods_key_is_absent_when_the_type_declares_none() {
6489        let e = extract_src("namespace App.Plain { public static class Utils { public static void Go(Widget w) { } } }");
6490        let d = find_def(&e, "App.Plain.Utils").expect("Utils def present");
6491        assert_eq!(
6492            def_json_keys(d),
6493            vec!["id", "name", "namespace", "kind", "line", "methods"],
6494            "pre-stage-3 bytes preserved"
6495        );
6496    }
6497    // --- testMethods -----------------------------------------------------------
6498
6499    #[test]
6500    fn test_coverage_xunit_fact_and_theory_land_in_test_methods_serialized_last_after_bases() {
6501        let e = extract_src(
6502            "namespace App.Tests;\n\npublic class WidgetTests : TestBase\n{\n  [Fact]\n  public void ComputesTotal() { }\n\n  [Theory]\n  [InlineData(1)]\n  public void RejectsEmptyCart(int n) { }\n\n  public void Helper() { }\n}\n",
6503        );
6504        let d = find_def(&e, "App.Tests.WidgetTests").expect("WidgetTests def present");
6505        assert_eq!(
6506            d.test_methods,
6507            vec!["ComputesTotal", "RejectsEmptyCart"],
6508            "source order; the unattributed helper is a method but not a test"
6509        );
6510        assert_eq!(
6511            def_json_keys(d),
6512            vec![
6513                "id",
6514                "name",
6515                "namespace",
6516                "kind",
6517                "line",
6518                "methods",
6519                "bases",
6520                "testMethods"
6521            ],
6522            "testMethods lands LAST -- after bases, which was the final fact before this stage"
6523        );
6524    }
6525
6526    #[test]
6527    fn test_coverage_suffixed_qualified_targeted_and_shared_bracket_attribute_forms_all_match() {
6528        let e = extract_src(
6529            "namespace App.Tests;\n\npublic class SpellingTests\n{\n  [FactAttribute]\n  public void Suffixed() { }\n\n  [Xunit.Fact]\n  public void Qualified() { }\n\n  [method: Fact]\n  public void Targeted() { }\n\n  [Fact, Trait(\"speed\", \"fast\")]\n  public void SharesABracket() { }\n\n  [method: Xunit.FactAttribute]\n  public void EveryFormAtOnce() { }\n}\n",
6530        );
6531        let d = find_def(&e, "App.Tests.SpellingTests").expect("SpellingTests def present");
6532        assert_eq!(
6533            d.test_methods,
6534            vec!["Suffixed", "Qualified", "Targeted", "SharesABracket", "EveryFormAtOnce"],
6535            "the last dotted segment is compared with and without the Attribute suffix, per attribute node, not per bracket pair"
6536        );
6537    }
6538
6539    #[test]
6540    fn test_coverage_nunit_attributes_match_without_a_test_fixture_on_the_class() {
6541        let e = extract_src(
6542            "namespace App.Tests;\n\npublic class OrderServiceTests\n{\n  [Test]\n  public void Renders() { }\n\n  [TestCase(1, 2)]\n  public void Adds(int a, int b) { }\n\n  [TestCaseSource(nameof(Cases))]\n  public void Divides(int a) { }\n}\n",
6543        );
6544        let d = find_def(&e, "App.Tests.OrderServiceTests").expect("OrderServiceTests def present");
6545        assert_eq!(
6546            d.test_methods,
6547            vec!["Renders", "Adds", "Divides"],
6548            "NUnit makes the class attribute optional, so requiring one would drop real tests"
6549        );
6550    }
6551
6552    #[test]
6553    fn test_coverage_lifecycle_data_source_and_class_container_attributes_never_mark_a_method() {
6554        let e = extract_src(
6555            "namespace App.Tests;\n\npublic class NotTests\n{\n  [SetUp]\n  public void Prepare() { }\n\n  [TearDown]\n  public void Cleanup() { }\n\n  [OneTimeSetUp]\n  public void Once() { }\n\n  [OneTimeTearDown]\n  public void Finally() { }\n\n  [TestInitialize]\n  public void Init() { }\n\n  [TestCleanup]\n  public void Done() { }\n\n  [InlineData(1)]\n  public void OnlyInline(int n) { }\n\n  [MemberData(nameof(Cases))]\n  public void OnlyMember(int n) { }\n\n  [ClassData(typeof(Cases))]\n  public void OnlyClassData(int n) { }\n\n  [DataRow(1)]\n  public void OnlyRow(int n) { }\n\n  [DynamicData(nameof(Cases))]\n  public void OnlyDynamic(int n) { }\n\n  [TestFixture]\n  public void FixtureOnAMethod() { }\n\n  [TestClass]\n  public void ClassMarkerOnAMethod() { }\n}\n",
6556        );
6557        let d = find_def(&e, "App.Tests.NotTests").expect("NotTests def present");
6558        assert!(d.test_methods.is_empty());
6559        assert_eq!(
6560            def_json_keys(d),
6561            vec!["id", "name", "namespace", "kind", "line", "methods"],
6562            "not one of these marks a test, so the key is absent entirely and the def keeps its pre-stage bytes"
6563        );
6564    }
6565
6566    #[test]
6567    fn test_coverage_mstest_methods_count_only_inside_a_test_class() {
6568        let source = |class_attribute: &str| {
6569            format!(
6570                "namespace App.Tests;\n\n{class_attribute}public class CartTests\n{{\n  [TestMethod]\n  public void Places() {{ }}\n\n  [DataTestMethod]\n  [DataRow(1)]\n  public void Prices(int n) {{ }}\n}}\n"
6571            )
6572        };
6573        let ungated = extract_src(&source(""));
6574        assert!(
6575            find_def(&ungated, "App.Tests.CartTests").expect("CartTests def present").test_methods.is_empty(),
6576            "MSTest does not discover a [TestMethod] whose class lacks [TestClass] -- neither does this"
6577        );
6578
6579        let gated = extract_src(&source("[TestClass]\n"));
6580        assert_eq!(
6581            find_def(&gated, "App.Tests.CartTests")
6582                .expect("CartTests def present")
6583                .test_methods,
6584            vec!["Places", "Prices"]
6585        );
6586    }
6587
6588    #[test]
6589    fn test_coverage_a_nested_type_does_not_inherit_an_enclosing_test_class() {
6590        let e = extract_src(
6591            "namespace App.Tests;\n\n[TestClass]\npublic class OuterTests\n{\n  [TestMethod]\n  public void Outer() { }\n\n  public class Inner\n  {\n    [TestMethod]\n    public void Nested() { }\n  }\n}\n",
6592        );
6593        assert_eq!(
6594            find_def(&e, "App.Tests.OuterTests")
6595                .expect("OuterTests def present")
6596                .test_methods,
6597            vec!["Outer"]
6598        );
6599        assert!(
6600            find_def(&e, "App.Tests.OuterTests+Inner")
6601                .expect("Inner def present")
6602                .test_methods
6603                .is_empty(),
6604            "each type computes its own list from its OWN attribute_list"
6605        );
6606    }
6607
6608    #[test]
6609    fn test_coverage_an_interface_or_enum_body_never_emits_test_methods() {
6610        let e = extract_src(
6611            "namespace App.Tests;\n\npublic interface ITestContract\n{\n  [Fact]\n  void Runs();\n}\n\npublic enum Speed\n{\n  Fast,\n  Slow,\n}\n",
6612        );
6613        assert!(find_def(&e, "App.Tests.ITestContract")
6614            .expect("interface def present")
6615            .test_methods
6616            .is_empty());
6617        assert!(find_def(&e, "App.Tests.Speed")
6618            .expect("enum def present")
6619            .test_methods
6620            .is_empty());
6621        assert!(find_def(&e, "App.Tests.Speed.Fast")
6622            .expect("enum-member def present")
6623            .test_methods
6624            .is_empty());
6625    }
6626
6627    #[test]
6628    fn test_coverage_a_struct_and_a_record_carry_test_methods_too_and_a_local_function_never_does()
6629    {
6630        let e = extract_src(
6631            "namespace App.Tests;\n\npublic struct ValueTests\n{\n  [Fact]\n  public void Holds() { }\n}\n\npublic record RecordTests\n{\n  [Fact]\n  public void Keeps()\n  {\n    [Fact]\n    void Inner() { }\n  }\n}\n",
6632        );
6633        assert_eq!(
6634            find_def(&e, "App.Tests.ValueTests")
6635                .expect("struct def present")
6636                .test_methods,
6637            vec!["Holds"]
6638        );
6639        assert_eq!(
6640            find_def(&e, "App.Tests.RecordTests")
6641                .expect("record def present")
6642                .test_methods,
6643            vec!["Keeps"],
6644            "a local function is not a method_declaration at type body level"
6645        );
6646    }
6647
6648    // --- v8: the ref `outer_types` enclosing-type stack -------------------
6649
6650    fn find_ref<'a>(e: &'a Extraction, kind: &str, name: &str) -> Option<&'a RefRecord> {
6651        e.refs.iter().find(|r| r.kind == kind && r.name == name)
6652    }
6653
6654    #[test]
6655    fn v8_a_ref_inside_a_nested_type_records_outer_types_outermost_first() {
6656        let e = extract_src(
6657            "namespace App.Core;\n\npublic class Outer\n{\n  public class Inner\n  {\n    private Marker _m;\n  }\n}\n\npublic class Marker { }\n",
6658        );
6659        let r = find_ref(&e, "uses-type", "Marker").expect("the field type ref is recorded");
6660        // Outermost first is the order type_id joins with "+", so the resolver
6661        // rebuilds a nested id by prefix rather than by reversing.
6662        assert_eq!(
6663            r.outer_types,
6664            vec!["Outer".to_string(), "Inner".to_string()]
6665        );
6666    }
6667
6668    #[test]
6669    fn v8_a_namespace_level_ref_and_an_imports_ref_carry_no_outer_types() {
6670        let e = extract_src(
6671            "using App.Other;\n\nnamespace App.Core;\n\npublic class Widget : Marker { }\n",
6672        );
6673        assert!(find_ref(&e, "imports", "App.Other")
6674            .expect("using ref")
6675            .outer_types
6676            .is_empty());
6677        assert!(find_ref(&e, "inherits", "Marker")
6678            .expect("base ref")
6679            .outer_types
6680            .is_empty());
6681    }
6682
6683    #[test]
6684    fn v8_a_member_ref_carries_outer_types_alongside_every_other_receiver_fact() {
6685        let e = extract_src(
6686            "namespace App.Core;\n\npublic class Outer\n{\n  public class Inner\n  {\n    private Store<int> _s;\n\n    public void Go()\n    {\n      _s.Add(1);\n      Box<int>.Make();\n    }\n  }\n}\n",
6687        );
6688        let with_receiver = e
6689            .refs
6690            .iter()
6691            .find(|r| r.member.as_deref() == Some("Add"))
6692            .expect("receiver-fact ref");
6693        assert_eq!(with_receiver.receiver_type.as_deref(), Some("Store"));
6694        assert_eq!(with_receiver.receiver_args, Some(vec!["int".to_string()]));
6695        assert_eq!(
6696            with_receiver.outer_types,
6697            vec!["Outer".to_string(), "Inner".to_string()]
6698        );
6699        // A generic qualifier earns no receiver fact; the stack lands on it all
6700        // the same.
6701        let generic = e
6702            .refs
6703            .iter()
6704            .find(|r| r.member.as_deref() == Some("Make"))
6705            .expect("generic-qualifier ref");
6706        assert!(generic.generic);
6707        assert_eq!(
6708            generic.outer_types,
6709            vec!["Outer".to_string(), "Inner".to_string()]
6710        );
6711    }
6712
6713    #[test]
6714    fn v8_a_base_list_ref_carries_the_declaring_types_outer_stack_with_self_excluded() {
6715        let e = extract_src(
6716            "namespace App.Core;\n\npublic class Outer\n{\n  public class Inner : Marker\n  {\n  }\n}\n",
6717        );
6718        // The same stack record_type_def used to build Inner's own id.
6719        assert_eq!(
6720            find_ref(&e, "inherits", "Marker")
6721                .expect("base ref")
6722                .outer_types,
6723            vec!["Outer".to_string()]
6724        );
6725    }
6726
6727    #[test]
6728    fn v8_a_dotted_chain_tail_ref_carries_the_sites_own_outer_types() {
6729        let e = extract_src(
6730            "namespace App.Core;\n\npublic class Outer\n{\n  public class Inner\n  {\n    public void Go() { Alpha.Beta.Gamma(); }\n  }\n}\n",
6731        );
6732        let tail = e
6733            .refs
6734            .iter()
6735            .find(|r| r.qualified.as_deref() == Some("Alpha.Beta"))
6736            .expect("chain-tail ref");
6737        // Positional: the stack of the SITE, never inherited from the head.
6738        assert_eq!(
6739            tail.outer_types,
6740            vec!["Outer".to_string(), "Inner".to_string()]
6741        );
6742    }
6743
6744    // --- The per-file `names` list -------------------------------
6745
6746    fn names_of(e: &Extraction) -> Vec<(&str, &str, usize, &str)> {
6747        e.names
6748            .iter()
6749            .map(|n| (n.name.as_str(), n.kind.as_str(), n.line, n.owner.as_str()))
6750            .collect()
6751    }
6752
6753    #[test]
6754    fn names_record_every_member_kind_with_no_accessibility_filter() {
6755        let e = extract_src(
6756            "namespace Ns;\npublic class Ledger\n{\n\tprivate int _a, _b;\n\tpublic string Label { get; set; }\n\tpublic event EventHandler Retired;\n\tpublic event EventHandler Sealed { add { } remove { } }\n\tprivate void Hidden() { }\n}\n",
6757        );
6758        assert_eq!(
6759            names_of(&e),
6760            vec![
6761                ("_a", "field", 4, "Ns.Ledger"),
6762                ("_b", "field", 4, "Ns.Ledger"),
6763                ("Label", "property", 5, "Ns.Ledger"),
6764                ("Retired", "event", 6, "Ns.Ledger"),
6765                ("Sealed", "event", 7, "Ns.Ledger"),
6766                ("Hidden", "method", 8, "Ns.Ledger"),
6767            ]
6768        );
6769    }
6770
6771    #[test]
6772    fn a_names_line_is_the_name_token_row_not_the_attribute_the_declaration_starts_at() {
6773        let e = extract_src(
6774            "namespace Ns;\npublic class Ledger\n{\n\t[Obsolete]\n\tpublic int Total { get; }\n}\n",
6775        );
6776        assert_eq!(
6777            names_of(&e),
6778            vec![("Total", "property", 5, "Ns.Ledger")],
6779            "line 5 is the name, line 4 is the attribute"
6780        );
6781    }
6782
6783    #[test]
6784    fn overloads_are_two_names_and_a_nested_type_owns_its_own_members() {
6785        let e = extract_src(
6786            "namespace Ns;\npublic class Ledger\n{\n\tpublic void Add() { }\n\tpublic void Add(int n) { }\n\tpublic class Tally\n\t{\n\t\tpublic void Add(long n) { }\n\t}\n}\n",
6787        );
6788        assert_eq!(
6789            names_of(&e),
6790            vec![
6791                ("Add", "method", 4, "Ns.Ledger"),
6792                ("Add", "method", 5, "Ns.Ledger"),
6793                ("Add", "method", 8, "Ns.Ledger+Tally"),
6794            ],
6795            "two overloads are two declarations at two lines; the nested type's member is owned by the nested id"
6796        );
6797    }
6798
6799    #[test]
6800    fn an_interface_body_and_an_enum_body_contribute_what_they_declare_and_nothing_else() {
6801        let e = extract_src("namespace Ns;\npublic interface ISink\n{\n\tvoid Accept();\n}\npublic enum Mode\n{\n\tOn,\n}\n");
6802        assert_eq!(
6803            names_of(&e),
6804            vec![("Accept", "method", 4, "Ns.ISink")],
6805            "an enum's members are defs already, never names rows"
6806        );
6807    }
6808
6809    // -----------------------------------------------------------------
6810    // The TS/TSX reference fragment. The cross-file resolution
6811    // these facts feed is tsgraph.rs's own test module; everything here is
6812    // about what ONE file says, which is the whole of this extractor's job.
6813    // -----------------------------------------------------------------
6814
6815    fn ts_fragment(src: &str, grammar: crate::parse::TsGrammar) -> TsFragment {
6816        let units = crate::parse::utf16_units(src);
6817        let tree = crate::parse::parse_ts_js(&units, grammar).expect("fixture parses");
6818        extract_ts_fragment(tree.root_node(), &crate::parse::utf16_bytes(&units))
6819    }
6820
6821    fn ref_tuples(f: &TsFragment) -> Vec<(&str, &str, Option<&str>, usize)> {
6822        f.refs
6823            .iter()
6824            .map(|r| {
6825                (
6826                    r.kind.as_str(),
6827                    r.name.as_str(),
6828                    r.member.as_deref(),
6829                    r.line,
6830                )
6831            })
6832            .collect()
6833    }
6834
6835    #[test]
6836    fn an_import_clause_records_default_named_aliased_and_namespace_bindings() {
6837        let f = ts_fragment(
6838            "import Thing, { one, two as alias } from './m';\nimport * as ns from './n';\n",
6839            crate::parse::TsGrammar::Typescript,
6840        );
6841        assert_eq!(f.imports.len(), 2);
6842        assert_eq!(f.imports[0].spec, "./m");
6843        assert_eq!(f.imports[0].line, 1);
6844        let first: Vec<(&str, &str)> = f.imports[0]
6845            .bindings
6846            .iter()
6847            .map(|b| (b.local.as_str(), b.imported.as_str()))
6848            .collect();
6849        assert_eq!(
6850            first,
6851            vec![("Thing", "default"), ("one", "one"), ("alias", "two")]
6852        );
6853        let second: Vec<(&str, &str)> = f.imports[1]
6854            .bindings
6855            .iter()
6856            .map(|b| (b.local.as_str(), b.imported.as_str()))
6857            .collect();
6858        assert_eq!(
6859            second,
6860            vec![("ns", "*")],
6861            "a namespace clause binds '*', never a name of its own"
6862        );
6863    }
6864
6865    #[test]
6866    fn a_star_reexport_a_named_one_and_a_namespace_one_are_three_different_rows() {
6867        let f = ts_fragment(
6868            "export * from './a';\nexport { x as y } from './b';\nexport * as NS from './c';\n",
6869            crate::parse::TsGrammar::Typescript,
6870        );
6871        assert_eq!(f.reexports.len(), 3);
6872        assert!(f.reexports[0].star && f.reexports[0].names.is_empty());
6873        assert!(!f.reexports[1].star);
6874        assert_eq!(f.reexports[1].names[0].exported, "y");
6875        assert_eq!(f.reexports[1].names[0].imported, "x");
6876        assert!(
6877            !f.reexports[2].star && f.reexports[2].names.is_empty(),
6878            "`export * as NS` contributes its import edge and no name mapping"
6879        );
6880    }
6881
6882    #[test]
6883    fn only_names_this_file_declares_earn_a_def_and_the_default_name_is_recorded_separately() {
6884        let f = ts_fragment(
6885            "export function run() {}\nconst helper = 1;\nexport { helper };\nexport { missing } from './elsewhere';\nexport default run;\n",
6886            crate::parse::TsGrammar::Typescript,
6887        );
6888        let defs: Vec<(&str, &str, usize)> = f
6889            .defs
6890            .iter()
6891            .map(|d| (d.name.as_str(), d.kind.as_str(), d.line))
6892            .collect();
6893        assert_eq!(defs, vec![("run", "function", 1), ("helper", "const", 2)]);
6894        assert_eq!(
6895            f.default.as_deref(),
6896            Some("run"),
6897            "one def, two importable names -- never a second row"
6898        );
6899    }
6900
6901    #[test]
6902    fn a_commonjs_file_exports_by_name_and_binds_require_the_same_way_an_import_clause_does() {
6903        let f = ts_fragment(
6904            "const { logInfo } = require('./logger');\nconst bag = require('./logger');\nfunction report() { return logInfo(1) + bag.logInfo(2); }\nmodule.exports = { report };\n",
6905            crate::parse::TsGrammar::Javascript,
6906        );
6907        assert_eq!(
6908            f.defs.iter().map(|d| d.name.as_str()).collect::<Vec<_>>(),
6909            vec!["report"]
6910        );
6911        let bindings: Vec<(&str, &str)> = f
6912            .imports
6913            .iter()
6914            .flat_map(|i| i.bindings.iter())
6915            .map(|b| (b.local.as_str(), b.imported.as_str()))
6916            .collect();
6917        assert_eq!(bindings, vec![("logInfo", "logInfo"), ("bag", "*")]);
6918        assert_eq!(
6919            ref_tuples(&f),
6920            vec![
6921                ("call", "logInfo", None, 3),
6922                ("call", "bag", Some("logInfo"), 3)
6923            ],
6924            "a member call records the QUALIFIER and its property, never the chain's tail alone"
6925        );
6926    }
6927
6928    #[test]
6929    fn a_dispatching_call_consumes_its_argument_so_it_is_never_also_a_plain_call() {
6930        let f = ts_fragment(
6931            "import { load, reset } from './actions';\nexport function go(dispatch) {\n  dispatch(load());\n  dispatch(reset);\n}\n",
6932            crate::parse::TsGrammar::Typescript,
6933        );
6934        assert_eq!(
6935            ref_tuples(&f),
6936            vec![
6937                ("dispatch", "load", None, 3),
6938                ("dispatch", "reset", None, 4)
6939            ],
6940            "the argument is the action creator; the outer dispatch(...) is not itself a call ref"
6941        );
6942    }
6943
6944    #[test]
6945    fn a_jsx_tag_is_a_reference_only_when_it_names_a_component_and_that_name_is_known() {
6946        let f = ts_fragment(
6947            "import { Card, Panel } from './ui';\nexport function View() {\n  return <div><Card /><Panel.Header /><section /><Unknown /></div>;\n}\n",
6948            crate::parse::TsGrammar::Tsx,
6949        );
6950        assert_eq!(
6951            ref_tuples(&f),
6952            vec![
6953                ("jsx-use", "Card", None, 3),
6954                ("jsx-use", "Panel", Some("Header"), 3)
6955            ],
6956            "lowercase tags are intrinsic elements; an unbound capitalised one is not a known name"
6957        );
6958    }
6959
6960    #[test]
6961    fn a_reference_to_a_name_this_file_neither_imports_nor_exports_is_never_recorded() {
6962        let f = ts_fragment(
6963            "export function go() {\n  const local = () => 1;\n  return local() + globalThing();\n}\n",
6964            crate::parse::TsGrammar::Typescript,
6965        );
6966        assert!(
6967            ref_tuples(&f).is_empty(),
6968            "the known-name filter is the contract, not an optimisation"
6969        );
6970    }
6971
6972    #[test]
6973    fn a_new_expression_records_the_constructor_as_a_call() {
6974        let f = ts_fragment(
6975            "import { Service } from './service';\nexport const make = () => new Service();\n",
6976            crate::parse::TsGrammar::Typescript,
6977        );
6978        assert_eq!(ref_tuples(&f), vec![("call", "Service", None, 2)]);
6979    }
6980
6981    #[test]
6982    fn a_file_with_no_default_export_keeps_the_shorter_fragment_shape() {
6983        let f = ts_fragment("export const x = 1;\n", crate::parse::TsGrammar::Typescript);
6984        assert_eq!(f.default, None);
6985        let json = serde_json::to_string(&f).unwrap();
6986        assert_eq!(
6987            json,
6988            r#"{"ts":1,"defs":[{"name":"x","kind":"const","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[]}"#
6989        );
6990    }
6991
6992    #[test]
6993    fn the_fragment_serializes_ts_first_and_default_last() {
6994        let f = ts_fragment(
6995            "export function run() {}\nexport default run;\n",
6996            crate::parse::TsGrammar::Typescript,
6997        );
6998        let json = serde_json::to_string(&f).unwrap();
6999        assert_eq!(
7000            json,
7001            r#"{"ts":1,"defs":[{"name":"run","kind":"function","line":1,"endLine":1}],"imports":[],"reexports":[],"refs":[],"default":"run"}"#
7002        );
7003    }
7004
7005    #[test]
7006    fn typescript_fragment_records_multiline_declaration_end_line() {
7007        let f = ts_fragment(
7008            "export interface Widget {\n  id: number;\n}\n",
7009            crate::parse::TsGrammar::Typescript,
7010        );
7011        assert_eq!((f.defs[0].line, f.defs[0].end_line), (1, 3));
7012    }
7013
7014    // --- Property types and var-from-invocation ----------
7015
7016    #[test]
7017    fn ds0012_def_records_property_types_in_source_order_with_generic_args() {
7018        let e = extract_src(
7019            r#"
7020namespace App.PropTypes;
7021
7022public class Widget
7023{
7024  public Settings Config { get; set; }
7025  public string Label { get; set; }
7026  public Box<Gadget> Slots { get; set; }
7027  public Settings Config { get; set; }
7028}
7029"#,
7030        );
7031        let d = find_def(&e, "App.PropTypes.Widget").expect("Widget def present");
7032        assert_eq!(d.properties, vec!["Config", "Label", "Slots"]);
7033        let recorded: Vec<_> = d
7034            .property_types
7035            .iter()
7036            .map(|(n, f)| (n.as_str(), f.type_name.as_str(), f.args.as_ref()))
7037            .collect();
7038        // A predefined type vouches for nothing, exactly as it does for a local
7039        // or a method return, so `Label` has no entry -- the map is parallel to
7040        // `properties` but not equal in length.
7041        assert_eq!(
7042            recorded,
7043            vec![
7044                ("Config", "Settings", None),
7045                ("Slots", "Box", Some(&vec!["Gadget".to_string()]))
7046            ]
7047        );
7048    }
7049
7050    #[test]
7051    fn ds0012_a_property_vouches_for_its_own_name_like_a_field() {
7052        let e = extract_src(
7053            r#"
7054namespace App.PropFacts;
7055
7056public class Host
7057{
7058  private Widget _field;
7059  public Widget Current { get; set; }
7060  public string Name { get; set; }
7061
7062  public void Run()
7063  {
7064    _field.Render();
7065    Current.Render();
7066    Name.Trim();
7067  }
7068}
7069"#,
7070        );
7071        // A predefined property type is still no fact, and the name stays
7072        // TAKEN: `Name` can never be read back as a TYPE named Name.
7073        assert_eq!(
7074            member_facts(&e),
7075            vec![
7076                ("Render", Some("Widget")),
7077                ("Render", Some("Widget")),
7078                ("Trim", None)
7079            ]
7080        );
7081    }
7082
7083    #[test]
7084    fn ds0012_receiver_property_owner_is_recorded_only_for_a_typed_two_segment_chain() {
7085        let e = extract_src(
7086            r#"
7087namespace App.ChainHeads;
7088
7089public class Host
7090{
7091  private Widget _widget;
7092  private string _text;
7093
7094  public void Run(Widget param)
7095  {
7096    _widget.Config.Reload();
7097    param.Config.Reload();
7098    _text.Config.Reload();
7099    unknown.Config.Reload();
7100    _widget.Config.Inner.Reload();
7101    App.Other.Thing.Reload();
7102  }
7103}
7104"#,
7105        );
7106        let owners: Vec<_> = e
7107            .refs
7108            .iter()
7109            .filter(|r| r.kind == "uses-member")
7110            .map(|r| {
7111                (
7112                    r.qualified.as_deref().unwrap_or(r.name.as_str()),
7113                    r.receiver_property_owner.as_deref(),
7114                )
7115            })
7116            .collect();
7117        assert_eq!(
7118            owners,
7119            vec![
7120                ("_widget.Config", Some("Widget")),
7121                // The head window itself is a BARE qualifier: a receiverType,
7122                // never an owner.
7123                ("_widget", None),
7124                ("param.Config", Some("Widget")),
7125                ("param", None),
7126                // Head typed `string`: no fact, so no owner.
7127                ("_text.Config", None),
7128                ("_text", None),
7129                // Head nothing in scope declares: no fact, so no owner.
7130                ("unknown.Config", None),
7131                ("unknown", None),
7132                // Three segments: only the innermost pair is a hop this fact
7133                // can start.
7134                ("_widget.Config.Inner", None),
7135                ("_widget.Config", Some("Widget")),
7136                ("_widget", None),
7137                // A namespace path is not a typed head.
7138                ("App.Other.Thing", None),
7139                ("App.Other", None),
7140                ("App", None),
7141            ]
7142        );
7143    }
7144
7145    #[test]
7146    fn ds0010_var_from_a_qualified_invocation_records_the_callee_owner_and_member() {
7147        let e = extract_src(
7148            r#"
7149namespace App.CallFacts;
7150
7151public class Host
7152{
7153  private Factory _factory;
7154
7155  public void Run()
7156  {
7157    var made = _factory.Make();
7158    made.Render();
7159    var stat = Factory.Create();
7160    stat.Render();
7161    var bare = Compute();
7162    bare.Render();
7163    var chained = made.Wrap();
7164    chained.Render();
7165  }
7166}
7167"#,
7168        );
7169        let facts: Vec<_> = e
7170            .refs
7171            .iter()
7172            .filter(|r| r.kind == "uses-member" && r.member.as_deref() == Some("Render"))
7173            .map(|r| {
7174                (
7175                    r.name.as_str(),
7176                    r.receiver_type.as_deref(),
7177                    r.receiver_call_owner.as_deref(),
7178                    r.receiver_call_member.as_deref(),
7179                )
7180            })
7181            .collect();
7182        assert_eq!(
7183            facts,
7184            vec![
7185                // An instance callee: the owner is what the QUALIFIER's own
7186                // fact says.
7187                ("made", None, Some("Factory"), Some("Make")),
7188                // A static callee: nothing in scope claims the name, so the
7189                // qualifier text is itself the type name candidate.
7190                ("stat", None, Some("Factory"), Some("Create")),
7191                // A bare call has no qualifier to put through the ladder.
7192                ("bare", None, None, None),
7193                // The qualifier is itself one of these locals: one hop, never
7194                // a chain.
7195                ("chained", None, None, None),
7196            ]
7197        );
7198    }
7199
7200    #[test]
7201    fn ds0010_a_conflicting_second_declaration_cancels_the_call_fact() {
7202        let e = extract_src(
7203            r#"
7204namespace App.CallConflict;
7205
7206public class Host
7207{
7208  private Widget made;
7209
7210  public void Run(bool flag)
7211  {
7212    if (flag) { var made = Factory.Make(); made.Render(); }
7213    else { Gadget made = new Gadget(); made.Render(); }
7214  }
7215}
7216"#,
7217        );
7218        let facts: Vec<_> = e
7219            .refs
7220            .iter()
7221            .filter(|r| r.kind == "uses-member" && r.member.as_deref() == Some("Render"))
7222            .map(|r| (r.receiver_type.as_deref(), r.receiver_call_owner.as_deref()))
7223            .collect();
7224        // Both windows read the same conflicted slot: no type fact, no call
7225        // fact, and no fall-through to the same-named FIELD of a different
7226        // type.
7227        assert_eq!(facts, vec![(None, None), (None, None)]);
7228    }
7229
7230    // --- Foreach element type fact -----------------------------------
7231
7232    #[test]
7233    fn ds0011_var_over_a_generic_collection_resolves_the_single_type_argument() {
7234        let e = extract_src(
7235            r#"
7236namespace App.ForEachVar;
7237
7238public class Host
7239{
7240  private List<Widget> _field;
7241
7242  public void Run(IEnumerable<Widget> param)
7243  {
7244    List<Widget> local = null;
7245    foreach (var a in _field) { a.Go(); }
7246    foreach (var b in param) { b.Go(); }
7247    foreach (var c in local) { c.Go(); }
7248  }
7249}
7250"#,
7251        );
7252        // A field, a parameter and a local: the same lookup the call
7253        // hop uses for its qualifier, reading whichever table vouches for the
7254        // collection's name.
7255        assert_eq!(
7256            member_facts(&e),
7257            vec![
7258                ("Go", Some("Widget")),
7259                ("Go", Some("Widget")),
7260                ("Go", Some("Widget"))
7261            ]
7262        );
7263    }
7264
7265    #[test]
7266    fn ds0011_a_var_foreach_stays_unknown_unless_the_collection_is_a_bare_identifier_with_one_type_argument(
7267    ) {
7268        let e = extract_src(
7269            r#"
7270namespace App.ForEachUnknown;
7271
7272public class Host
7273{
7274  private Widget[] _array;
7275  private Dictionary<string, Widget> _pair;
7276  private Map map;
7277
7278  public void Run<T>(List<T> generic)
7279  {
7280    foreach (var a in GetItems()) { a.Go(); }
7281    foreach (var b in map.Items) { b.Go(); }
7282    foreach (var c in _array) { c.Go(); }
7283    foreach (var d in _pair) { d.Go(); }
7284    foreach (var f in generic) { f.Go(); }
7285  }
7286
7287  private List<Widget> GetItems() { return null; }
7288}
7289"#,
7290        );
7291        // A call, a dotted chain, an array (no top-level type-argument list
7292        // at all), a two-argument generic (not a SINGLE type argument), and a
7293        // one-argument generic whose argument is the method's own type
7294        // parameter (the wildcard descriptor, "*" -- nothing at this site
7295        // knows what it is bound to) all leave the loop variable exactly as
7296        // taken-but-unknown as every other unresolvable local. Filtered to
7297        // `Go` alone: `map.Items` is itself an ordinary tier-(e) member
7298        // access on the FIELD `map` (declared type `Map`), unrelated to this
7299        // ticket, so it earns its own unaffected receiver fact.
7300        let go_facts: Vec<_> = e
7301            .refs
7302            .iter()
7303            .filter(|r| r.member.as_deref() == Some("Go"))
7304            .map(|r| r.receiver_type.as_deref())
7305            .collect();
7306        assert_eq!(go_facts, vec![None, None, None, None, None]);
7307    }
7308
7309    #[test]
7310    fn ds0011_a_foreach_variable_used_as_another_foreachs_collection_is_never_a_fact() {
7311        let e = extract_src(
7312            r#"
7313namespace App.ForEachChain;
7314
7315public class Host
7316{
7317  private List<Widget> _bag;
7318
7319  public void Nested()
7320  {
7321    foreach (var outer in _bag)
7322    {
7323      outer.Go();
7324      foreach (var inner in outer) { inner.Go(); }
7325    }
7326  }
7327}
7328"#,
7329        );
7330        // `outer` resolves to a REAL fact (`_bag`'s single type argument), but
7331        // that derived fact never carries a type argument of its own (see
7332        // `collection_element_fact`), so `inner` -- one nested hop further --
7333        // finds no single argument to read and stays taken-but-unknown. One
7334        // hop, never a chain, structurally: a foreach element fact can be a
7335        // RECEIVER but never itself a collection another `var` derives from.
7336        assert_eq!(member_facts(&e), vec![("Go", Some("Widget")), ("Go", None)]);
7337    }
7338
7339    #[test]
7340    fn ds0011_a_foreach_variable_conflicting_with_another_declaration_stays_taken_but_unknown() {
7341        let e = extract_src(
7342            r#"
7343namespace App.ForEachConflict;
7344
7345public class Host
7346{
7347  private List<Widget> made;
7348
7349  public void Run(bool flag)
7350  {
7351    if (flag) { foreach (var made in Gadgets()) { made.Go(); } }
7352    else { Widget made = new Widget(); made.Go(); }
7353  }
7354
7355  private List<Gadget> Gadgets() { return null; }
7356}
7357"#,
7358        );
7359        // `made` is declared twice with conflicting shapes in sibling blocks
7360        // of the SAME method (a foreach-derived local in one arm, an
7361        // explicit local in the other): the flat member table collapses both
7362        // to the same taken-but-unknown slot -- the ordinary `add_fact`
7363        // conflict rule, unchanged by this ticket -- with no fall-through to
7364        // the enclosing field of the same name.
7365        assert_eq!(member_facts(&e), vec![("Go", None), ("Go", None)]);
7366    }
7367
7368    #[test]
7369    fn ds0011_a_destructuring_foreach_variable_is_left_alone() {
7370        // `foreach (var (a, b) in pairs)` has no single name for the element
7371        // fact to name -- deliberately outside this ticket's scope, and its
7372        // absence must not disturb an ordinary sibling declaration's own fact.
7373        let e = extract_src(
7374            r#"
7375namespace App.ForEachTuple;
7376
7377public class Host
7378{
7379  public void Run(List<(int, int)> pairs)
7380  {
7381    Widget w = new Widget();
7382    foreach (var (a, b) in pairs) { }
7383    w.Go();
7384  }
7385}
7386"#,
7387        );
7388        assert_eq!(member_facts(&e), vec![("Go", Some("Widget"))]);
7389    }
7390}