Skip to main content

_diffctx/utility/
needs.rs

1use std::sync::Arc;
2
3use once_cell::sync::Lazy;
4use regex::Regex;
5use rustc_hash::{FxHashMap, FxHashSet};
6
7use crate::config::needs::NEEDS;
8use crate::stopwords::CODE_STOPWORDS;
9use crate::types::{Fragment, FragmentId, extract_identifiers};
10
11static CALL_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(\w+)\s*\(").unwrap());
12static TYPE_REF_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"(?::|->)\s*([A-Z]\w+)").unwrap());
13static GENERIC_TYPE_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"[\[<,]\s*([A-Z]\w*)").unwrap());
14static INVARIANT_RE: Lazy<Regex> = Lazy::new(|| {
15    Regex::new(r"(?i)\b(?:assert|require|ensure|precondition|postcondition|invariant)\s*\(\s*(\w+)")
16        .unwrap()
17});
18static JS_IMPORT_RE: Lazy<Regex> =
19    Lazy::new(|| Regex::new(r#"import\s+\{([^}]+)\}\s+from\s+['"]([^'"]+)['"]"#).unwrap());
20static PY_IMPORT_RE: Lazy<Regex> =
21    Lazy::new(|| Regex::new(r"from\s+(\S+)\s+import\s+(.+)").unwrap());
22static JS_LOCAL_IMPORT_RE: Lazy<Regex> = Lazy::new(|| {
23    Regex::new(r#"import\s+(?:\{([^}]+)\}|([A-Z]\w+))\s+from\s+['"]([^'"]+)['"]"#).unwrap()
24});
25static TF_VAR_NEED_RE: Lazy<Regex> = Lazy::new(|| Regex::new(r"var\.(\w+)").unwrap());
26static TF_RES_REF_NEED_RE: Lazy<Regex> = Lazy::new(|| {
27    Regex::new(r"(?:^|[^.\w])([a-zA-Z]\w*)\.(\w+)(?:\[\*?\w*\])?\.[\w\[\]*]+").unwrap()
28});
29
30static LANGUAGE_BUILTINS: Lazy<FxHashSet<String>> = Lazy::new(|| {
31    [
32        "range",
33        "enumerate",
34        "zip",
35        "sorted",
36        "reversed",
37        "isinstance",
38        "issubclass",
39        "hasattr",
40        "getattr",
41        "setattr",
42        "delattr",
43        "callable",
44        "iter",
45        "next",
46        "any",
47        "all",
48        "abs",
49        "round",
50        "pow",
51        "divmod",
52        "repr",
53        "dir",
54        "vars",
55        "globals",
56        "locals",
57        "breakpoint",
58        "property",
59        "classmethod",
60        "staticmethod",
61        "dataclass",
62        "object",
63        "exception",
64        "baseexception",
65        "valueerror",
66        "typeerror",
67        "keyerror",
68        "indexerror",
69        "attributeerror",
70        "importerror",
71        "runtimeerror",
72        "stopiteration",
73        "generatorexit",
74        "oserror",
75        "ioerror",
76        "filenotfounderror",
77        "permissionerror",
78        "notimplementederror",
79        "zerodivisionerror",
80        "overflowerror",
81        "memoryerror",
82        "recursionerror",
83        "unicodeerror",
84        "assertionerror",
85        "lookuperror",
86        "arithmeticerror",
87        "array.from",
88        "object.keys",
89        "object.values",
90        "object.entries",
91        "array.isarray",
92        "number.isnan",
93        "number.isfinite",
94        "parseint",
95        "parsefloat",
96        "isnan",
97        "isfinite",
98        "settimeout",
99        "setinterval",
100        "clearinterval",
101        "cleartimeout",
102        "requestanimationframe",
103        "cancelanimationframe",
104        "typeof",
105        "void",
106        "make",
107        "append",
108        "panic",
109        "recover",
110        "cap",
111        "println",
112        "printf",
113        "sprintf",
114        "fprintf",
115        "errorf",
116        "vec",
117        "arc",
118        "unwrap",
119        "usestate",
120        "useeffect",
121        "usecontext",
122        "usereducer",
123        "usecallback",
124        "usememo",
125        "useref",
126        "uselayouteffect",
127        "useimperativehandle",
128        "usedebugvalue",
129        "useid",
130        "usetransition",
131        "usedeferredvalue",
132        "createcontext",
133        "forwardref",
134        "createref",
135        "suspense",
136        "strictmode",
137        "profiler",
138        "usenavigate",
139        "useparams",
140        "uselocation",
141        "usesearchparams",
142        "useloaderdata",
143        "useactiondata",
144        "usefetcher",
145        "useoutletcontext",
146        "usedispatch",
147        "useselector",
148        "usestore",
149        "usequery",
150        "usemutation",
151        "usesubscription",
152        "describe",
153        "beforeeach",
154        "aftereach",
155        "beforeall",
156        "afterall",
157        "assert",
158    ]
159    .iter()
160    .map(|s| s.to_string())
161    .collect()
162});
163
164static COMMENT_PREFIXES: &[&str] = &["#", "//", "* ", "/*", "--", "\"\"\"", "'''", "<!--"];
165static ONE_CLASS_PER_FILE_SUFFIXES: &[&str] = &[".swift", ".java", ".kt"];
166static TF_EXTENSIONS: &[&str] = &[".tf", ".tfvars", ".hcl"];
167static CONFIG_EXTENSIONS_FOR_DIFF: &[&str] = &[".yaml", ".yml", ".json", ".toml", ".ini"];
168static TF_SKIP_REF_TYPES: &[&str] = &[
169    "var",
170    "local",
171    "data",
172    "module",
173    "path",
174    "terraform",
175    "count",
176    "each",
177    "self",
178];
179
180#[derive(Debug, Clone)]
181pub struct InformationNeed {
182    pub need_type: String,
183    pub symbol: String,
184    pub scope: Option<Arc<str>>,
185    pub priority: f64,
186}
187
188fn parse_import_names(names_str: &str) -> FxHashSet<String> {
189    let mut result = FxHashSet::default();
190    for name in names_str.split(',') {
191        let name = name.trim().split(" as ").next().unwrap_or("").trim();
192        if !name.is_empty() {
193            result.insert(name.to_lowercase());
194        }
195    }
196    result
197}
198
199fn collect_external_symbols_from_lines(changed_lines: &[&str]) -> FxHashSet<String> {
200    let mut symbols = FxHashSet::default();
201    for line in changed_lines {
202        for m in JS_IMPORT_RE.captures_iter(line) {
203            let js_names = &m[1];
204            let js_source = &m[2];
205            if !js_source.starts_with('.') {
206                symbols.extend(parse_import_names(js_names));
207            }
208        }
209        for m in PY_IMPORT_RE.captures_iter(line) {
210            let py_module = &m[1];
211            let py_names = &m[2];
212            if !py_module.starts_with('.') {
213                symbols.extend(parse_import_names(py_names));
214            }
215        }
216    }
217    symbols
218}
219
220fn is_local_import(source: &str) -> bool {
221    source.starts_with('.') || source.starts_with("@/") || source.starts_with("~/")
222}
223
224fn add_needs_for_syms(
225    syms: &FxHashSet<String>,
226    needs: &mut FxHashMap<(String, String), InformationNeed>,
227) {
228    for sym in syms {
229        if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(sym) {
230            let key = ("definition".to_string(), sym.clone());
231            needs.entry(key).or_insert_with(|| InformationNeed {
232                need_type: "definition".to_string(),
233                symbol: sym.clone(),
234                scope: None,
235                priority: NEEDS.definition_priority,
236            });
237        }
238    }
239}
240
241fn collect_js_import_needs(line: &str, needs: &mut FxHashMap<(String, String), InformationNeed>) {
242    for m in JS_LOCAL_IMPORT_RE.captures_iter(line) {
243        let named = m.get(1).map(|x| x.as_str());
244        let default = m.get(2).map(|x| x.as_str());
245        let source = &m[3];
246        if !is_local_import(source) {
247            continue;
248        }
249        let mut syms = FxHashSet::default();
250        if let Some(named) = named {
251            syms = parse_import_names(named);
252        } else if let Some(default) = default {
253            syms.insert(default.to_lowercase());
254        }
255        add_needs_for_syms(&syms, needs);
256    }
257}
258
259fn collect_py_import_needs(line: &str, needs: &mut FxHashMap<(String, String), InformationNeed>) {
260    for m in PY_IMPORT_RE.captures_iter(line) {
261        let module = &m[1];
262        let names = &m[2];
263        if !module.starts_with('.') {
264            continue;
265        }
266        let syms = parse_import_names(names);
267        add_needs_for_syms(&syms, needs);
268    }
269}
270
271fn collect_import_needs(
272    changed_lines: &[&str],
273    needs: &mut FxHashMap<(String, String), InformationNeed>,
274) {
275    for line in changed_lines {
276        collect_js_import_needs(line, needs);
277        collect_py_import_needs(line, needs);
278    }
279}
280
281fn is_comment_line(line: &str) -> bool {
282    let stripped = line.trim_start();
283    COMMENT_PREFIXES.iter().any(|p| stripped.starts_with(p))
284}
285
286fn defines_strength(scope_match: bool, has_scope: bool) -> f64 {
287    if scope_match {
288        NEEDS.defines_scope_match
289    } else if !has_scope {
290        NEEDS.defines_no_scope
291    } else {
292        NEEDS.defines_other_scope
293    }
294}
295
296fn is_test_file(path: &str) -> bool {
297    crate::testfiles::is_test_path(std::path::Path::new(path))
298}
299
300fn is_test_fragment(frag: &Fragment) -> bool {
301    if is_test_file(frag.path()) {
302        return true;
303    }
304    if let Some(ref sym) = frag.symbol_name {
305        return sym.to_lowercase().starts_with("test_");
306    }
307    false
308}
309
310pub fn match_strength_typed(frag: &Fragment, need: &InformationNeed) -> f64 {
311    let sym = &need.symbol;
312    let frag_sym = frag
313        .symbol_name
314        .as_ref()
315        .map(|s| s.to_lowercase())
316        .unwrap_or_default();
317    let defines = !frag_sym.is_empty() && frag_sym == *sym;
318    let mentions = frag.identifiers.contains(sym);
319    let scope_match =
320        need.scope.is_some() && need.scope.as_ref().map(|s| s.as_ref()) == Some(frag.path());
321    let nt = need.need_type.as_str();
322
323    if nt == "impact" && scope_match {
324        return NEEDS.impact_scope_match;
325    }
326    if defines && !frag.kind.is_signature() {
327        return defines_strength(scope_match, need.scope.is_some());
328    }
329    if nt == "impact" && mentions && !defines {
330        return NEEDS.impact_mentions;
331    }
332    if defines && (frag.kind.is_signature() || nt == "signature") {
333        return NEEDS.signature_defines;
334    }
335    if nt == "test" && mentions && is_test_fragment(frag) {
336        return NEEDS.test_mentions;
337    }
338    if mentions {
339        NEEDS.mentions_fallback
340    } else {
341        0.0
342    }
343}
344
345fn extract_changed_lines(diff_text: &str) -> Vec<String> {
346    let mut result = Vec::new();
347    for line in diff_text.lines() {
348        let is_added = line.starts_with('+') && !line.starts_with("+++");
349        let is_removed = line.starts_with('-') && !line.starts_with("---");
350        if is_added || is_removed {
351            result.push(line[1..].to_string());
352        }
353    }
354    result
355}
356
357fn path_suffix(path: &str) -> String {
358    std::path::Path::new(path)
359        .extension()
360        .map(|e| format!(".{}", e.to_string_lossy().to_lowercase()))
361        .unwrap_or_default()
362}
363
364fn infer_core_symbol(frag: &Fragment) -> Option<String> {
365    if let Some(ref sym) = frag.symbol_name {
366        return Some(sym.to_lowercase());
367    }
368    let suffix = path_suffix(frag.path());
369    if ONE_CLASS_PER_FILE_SUFFIXES.contains(&suffix.as_str()) {
370        let stem = std::path::Path::new(frag.path())
371            .file_stem()
372            .map(|s| s.to_string_lossy().to_string());
373        if let Some(ref stem) = stem {
374            if stem.len() >= NEEDS.min_symbol_length {
375                return Some(stem.to_lowercase());
376            }
377        }
378    }
379    None
380}
381
382fn collect_core_needs(
383    all_fragments: &[Fragment],
384    core_ids: &FxHashSet<FragmentId>,
385    needs: &mut FxHashMap<(String, String), InformationNeed>,
386) -> FxHashSet<String> {
387    let mut core_symbol_names = FxHashSet::default();
388    let mut seen_paths = FxHashSet::default();
389    for frag in all_fragments {
390        if !core_ids.contains(&frag.id) {
391            continue;
392        }
393        let sym = match infer_core_symbol(frag) {
394            Some(s) => s,
395            None => continue,
396        };
397        if seen_paths.contains(frag.path()) && frag.symbol_name.is_none() {
398            continue;
399        }
400        if frag.symbol_name.is_none() {
401            seen_paths.insert(frag.path().to_string());
402        }
403        core_symbol_names.insert(sym.clone());
404        let key = ("impact".to_string(), sym.clone());
405        needs.entry(key).or_insert_with(|| InformationNeed {
406            need_type: "impact".to_string(),
407            symbol: sym,
408            scope: Some(frag.id.path.clone()),
409            priority: NEEDS.impact_priority,
410        });
411    }
412    core_symbol_names
413}
414
415fn process_line_for_needs(
416    line: &str,
417    external_syms: &FxHashSet<String>,
418    needs: &mut FxHashMap<(String, String), InformationNeed>,
419) {
420    for m in CALL_RE.captures_iter(line) {
421        let name = &m[1];
422        let low = name.to_lowercase();
423        if name.len() < NEEDS.min_symbol_length
424            || CODE_STOPWORDS.contains(&low)
425            || LANGUAGE_BUILTINS.contains(&low)
426        {
427            continue;
428        }
429        if external_syms.contains(&low) {
430            continue;
431        }
432        let key = ("definition".to_string(), low.clone());
433        needs.entry(key).or_insert_with(|| InformationNeed {
434            need_type: "definition".to_string(),
435            symbol: low,
436            scope: None,
437            priority: NEEDS.call_definition_priority,
438        });
439    }
440    // `external_syms` names what the diff imports from outside the repository,
441    // and it used to gate only the call loop above. A type annotation is just as
442    // unanswerable: `from typing import Optional` followed by `: Optional[int]`
443    // asked for a definition of `optional` that no fragment in the repo can
444    // provide, so the need stayed unsatisfied — which both inflates the
445    // diversity bonus for every candidate (`unsatisfied` never falls) and hands
446    // `mentions_fallback` strength to any unrelated file that happens to use the
447    // same stdlib type.
448    for m in TYPE_REF_RE.captures_iter(line) {
449        add_signature_need(&m[1], external_syms, needs);
450    }
451    for m in GENERIC_TYPE_RE.captures_iter(line) {
452        add_signature_need(&m[1], external_syms, needs);
453    }
454}
455
456fn add_signature_need(
457    raw: &str,
458    external_syms: &FxHashSet<String>,
459    needs: &mut FxHashMap<(String, String), InformationNeed>,
460) {
461    let sym = raw.to_lowercase();
462    if external_syms.contains(&sym) {
463        return;
464    }
465    let key = ("signature".to_string(), sym.clone());
466    needs.entry(key).or_insert_with(|| InformationNeed {
467        need_type: "signature".to_string(),
468        symbol: sym,
469        scope: None,
470        priority: NEEDS.signature_priority,
471    });
472}
473
474fn collect_diff_line_needs(
475    changed_lines: &[String],
476    needs: &mut FxHashMap<(String, String), InformationNeed>,
477) {
478    let line_refs: Vec<&str> = changed_lines.iter().map(|s| s.as_str()).collect();
479    let external_syms = collect_external_symbols_from_lines(&line_refs);
480    for line in changed_lines {
481        if !is_comment_line(line) {
482            process_line_for_needs(line, &external_syms, needs);
483        }
484    }
485}
486
487fn collect_test_needs(
488    all_fragments: &[Fragment],
489    core_symbol_names: &FxHashSet<String>,
490    needs: &mut FxHashMap<(String, String), InformationNeed>,
491) {
492    for frag in all_fragments {
493        if !is_test_fragment(frag) {
494            continue;
495        }
496        let tested = frag.symbol_name.as_ref().map(|s| {
497            let lower = s.to_lowercase();
498            lower.strip_prefix("test_").unwrap_or(&lower).to_string()
499        });
500        if let Some(ref tested) = tested {
501            if core_symbol_names.contains(tested)
502                || needs.contains_key(&("definition".to_string(), tested.clone()))
503            {
504                let key = ("test".to_string(), tested.clone());
505                needs.entry(key).or_insert_with(|| InformationNeed {
506                    need_type: "test".to_string(),
507                    symbol: tested.clone(),
508                    scope: None,
509                    priority: NEEDS.test_priority,
510                });
511            }
512        }
513    }
514}
515
516fn collect_invariant_needs(
517    changed_lines: &[String],
518    needs: &mut FxHashMap<(String, String), InformationNeed>,
519) {
520    for line in changed_lines {
521        for m in INVARIANT_RE.captures_iter(line) {
522            let sym = m[1].to_lowercase();
523            if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(&sym) {
524                let key = ("invariant".to_string(), sym.clone());
525                needs.entry(key).or_insert_with(|| InformationNeed {
526                    need_type: "invariant".to_string(),
527                    symbol: sym,
528                    scope: None,
529                    priority: NEEDS.invariant_priority,
530                });
531            }
532        }
533    }
534}
535
536fn is_terraform_diff(all_fragments: &[Fragment], core_ids: &FxHashSet<FragmentId>) -> bool {
537    all_fragments.iter().any(|f| {
538        core_ids.contains(&f.id) && TF_EXTENSIONS.contains(&path_suffix(f.path()).as_str())
539    })
540}
541
542fn is_config_only_diff(all_fragments: &[Fragment], core_ids: &FxHashSet<FragmentId>) -> bool {
543    let core_frags: Vec<&Fragment> = all_fragments
544        .iter()
545        .filter(|f| core_ids.contains(&f.id))
546        .collect();
547    !core_frags.is_empty()
548        && core_frags
549            .iter()
550            .all(|f| CONFIG_EXTENSIONS_FOR_DIFF.contains(&path_suffix(f.path()).as_str()))
551}
552
553fn collect_config_context_needs(
554    all_fragments: &[Fragment],
555    core_ids: &FxHashSet<FragmentId>,
556    needs: &mut FxHashMap<(String, String), InformationNeed>,
557) {
558    let covered: FxHashSet<String> = needs.values().map(|n| n.symbol.clone()).collect();
559    for frag in all_fragments {
560        if !core_ids.contains(&frag.id) {
561            continue;
562        }
563        for ident in &frag.identifiers {
564            if ident.len() >= NEEDS.background_min_ident_length && !covered.contains(ident) {
565                let key = ("background".to_string(), ident.clone());
566                needs.entry(key).or_insert_with(|| InformationNeed {
567                    need_type: "background".to_string(),
568                    symbol: ident.clone(),
569                    scope: None,
570                    priority: NEEDS.background_priority,
571                });
572            }
573        }
574    }
575}
576
577fn collect_terraform_needs(
578    changed_lines: &[String],
579    needs: &mut FxHashMap<(String, String), InformationNeed>,
580) {
581    for line in changed_lines {
582        for m in TF_VAR_NEED_RE.captures_iter(line) {
583            let sym = m[1].to_lowercase();
584            if sym.len() >= NEEDS.min_symbol_length && !CODE_STOPWORDS.contains(&sym) {
585                let key = ("definition".to_string(), sym.clone());
586                needs.entry(key).or_insert_with(|| InformationNeed {
587                    need_type: "definition".to_string(),
588                    symbol: sym,
589                    scope: None,
590                    priority: NEEDS.call_definition_priority,
591                });
592            }
593        }
594        for m in TF_RES_REF_NEED_RE.captures_iter(line) {
595            let ref_type = m[1].to_lowercase();
596            let ref_name = m[2].to_lowercase();
597            if TF_SKIP_REF_TYPES.contains(&ref_type.as_str()) {
598                continue;
599            }
600            let full_ref = format!("{}.{}", ref_type, ref_name);
601            let key = ("definition".to_string(), full_ref.clone());
602            needs.entry(key).or_insert_with(|| InformationNeed {
603                need_type: "definition".to_string(),
604                symbol: full_ref,
605                scope: None,
606                priority: NEEDS.definition_priority,
607            });
608        }
609    }
610}
611
612pub fn concepts_from_diff_text(
613    diff_text: &str,
614    changed_lines: Option<&[String]>,
615) -> FxHashSet<String> {
616    let owned;
617    let lines = match changed_lines {
618        Some(l) => l,
619        None => {
620            owned = extract_changed_lines(diff_text);
621            &owned
622        }
623    };
624    let text = lines.join("\n");
625
626    let expansion_stopwords: FxHashSet<String> = CODE_STOPWORDS
627        .iter()
628        .chain(LANGUAGE_BUILTINS.iter())
629        .cloned()
630        .collect();
631
632    let raw = extract_identifiers(&text, NEEDS.min_symbol_length);
633    raw.into_iter()
634        .filter(|id| !expansion_stopwords.contains(id))
635        .collect()
636}
637
638pub fn needs_from_diff(
639    all_fragments: &[Fragment],
640    core_ids: &FxHashSet<FragmentId>,
641    diff_text: &str,
642) -> Vec<InformationNeed> {
643    let mut needs: FxHashMap<(String, String), InformationNeed> = FxHashMap::default();
644    let changed_lines = extract_changed_lines(diff_text);
645
646    let core_symbol_names = collect_core_needs(all_fragments, core_ids, &mut needs);
647    collect_diff_line_needs(&changed_lines, &mut needs);
648    collect_import_needs(
649        &changed_lines.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
650        &mut needs,
651    );
652    collect_invariant_needs(&changed_lines, &mut needs);
653    collect_test_needs(all_fragments, &core_symbol_names, &mut needs);
654    if is_terraform_diff(all_fragments, core_ids) {
655        collect_terraform_needs(&changed_lines, &mut needs);
656    }
657    if is_config_only_diff(all_fragments, core_ids) {
658        collect_config_context_needs(all_fragments, core_ids, &mut needs);
659    }
660
661    if needs.is_empty() {
662        let fallback = concepts_from_diff_text(diff_text, Some(&changed_lines));
663        return fallback
664            .into_iter()
665            .map(|c| InformationNeed {
666                need_type: "definition".to_string(),
667                symbol: c,
668                scope: None,
669                priority: NEEDS.fallback_priority,
670            })
671            .collect();
672    }
673
674    let covered_symbols: FxHashSet<String> = needs.values().map(|n| n.symbol.clone()).collect();
675    for c in concepts_from_diff_text(diff_text, Some(&changed_lines)) {
676        if !covered_symbols.contains(&c) {
677            let key = ("background".to_string(), c.clone());
678            needs.entry(key).or_insert_with(|| InformationNeed {
679                need_type: "background".to_string(),
680                symbol: c,
681                scope: None,
682                priority: NEEDS.concept_background_priority,
683            });
684        }
685    }
686
687    needs.into_values().collect()
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693
694    fn signature_symbols(diff_text: &str) -> FxHashSet<String> {
695        needs_from_diff(&[], &FxHashSet::default(), diff_text)
696            .into_iter()
697            .filter(|n| n.need_type == "signature")
698            .map(|n| n.symbol)
699            .collect()
700    }
701
702    /// Imports from outside the repository cannot be answered by any fragment
703    /// in it. The call loop already skipped them; the type loops did not, so a
704    /// `typing`-imported annotation became a permanently unsatisfied need —
705    /// which keeps the diversity bonus's `unsatisfied` term high for every
706    /// candidate and lends match strength to any file mentioning the same
707    /// stdlib type.
708    #[test]
709    fn an_externally_imported_type_does_not_become_a_signature_need() {
710        let diff = concat!(
711            "+from typing import Optional\n",
712            "+def handle(x: Optional[int]) -> LocalResult:\n",
713        );
714        let syms = signature_symbols(diff);
715        assert!(
716            !syms.contains("optional"),
717            "an external type became a need: {syms:?}"
718        );
719        assert!(
720            syms.contains("localresult"),
721            "the repository-local type was lost: {syms:?}"
722        );
723    }
724
725    #[test]
726    fn an_externally_imported_generic_argument_is_skipped_too() {
727        let diff = concat!(
728            "+import { Observable } from 'rxjs'\n",
729            "+const s: Array<Observable> = wrap<LocalThing>(x)\n",
730        );
731        let syms = signature_symbols(diff);
732        assert!(
733            !syms.contains("observable"),
734            "an external generic argument became a need: {syms:?}"
735        );
736        assert!(
737            syms.contains("localthing"),
738            "the repository-local generic argument was lost: {syms:?}"
739        );
740    }
741}