fallow-core 3.18.0

Internal detector backend for fallow-engine and fallow-api
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
//! Detection of dead dependency-injection links: a Vue `inject(KEY)` or Svelte
//! `getContext(KEY)` whose symbol KEY is `provide`/`setContext`'d nowhere in the
//! analyzed project (the injected-never-provided direction).
//!
//! The key is a symbol with cross-file identity (an imported const or a
//! module-local symbol), so an unmatched key is a real dead-half: at runtime the
//! inject returns `undefined`, surfaced only at render time. No static tool in
//! the Vue/Svelte/Nuxt ecosystems catches this (they emit runtime-only warnings
//! or have unimplemented eslint proposals).
//!
//! Every gate below degrades by abstaining:
//! - **Dep-gated** on `vue` / `@vue/runtime-core` / `svelte`.
//! - **External-abstain**: a key imported from an npm PACKAGE is skipped, because
//!   the `provide` may live inside that package's own code (in `node_modules`,
//!   which fallow does not parse).
//! - **Public-API abstain**: a key that is part of this package's public API
//!   (re-exported from, or defined in, a non-private package entry point) is
//!   skipped, because a "bring-your-own-provider" library exports the key and an
//!   inject-composable for a downstream consumer to provide.
//! - **Dynamic-provide abstain**: if ANY reachable module provides a key fallow
//!   cannot pin to a stable symbol (a spread, a computed key, or a transient
//!   loop/parameter local), the whole project abstains, because a surviving
//!   inject finding could be falsely flagged. Mirrors the Pinia spread-return
//!   whole-object abstain.
//! - **Star-collision abstain**: a key supplied by ambiguous `export *` sources
//!   is unknown rather than unprovided, whether the provide or inject reaches it
//!   through the broken barrel.
//!
//! The provided set is built LIBERALLY (the composable `provide(KEY, _)` plus
//! app-level `*.provide(KEY, _)`): over-crediting a provided key can only
//! suppress a finding, never create one. The inject side emits conservatively.
//!
use rustc_hash::{FxHashMap, FxHashSet};

use fallow_types::extract::{DiFramework, DiRole, ExportName, ModuleInfo};

use crate::discover::FileId;
use crate::graph::{
    AmbiguityParticipants, EffectiveExportResolution, ExportNamespace, ModuleGraph,
};
use crate::resolve::ResolvedModule;
use crate::results::UnprovidedInject;
use crate::suppress::{IssueKind, SuppressionContext};

use super::members::{
    ExportKey, build_local_to_export_keys, export_key_with_origins, public_export_origin_keys,
};
use super::{LineOffsetsMap, byte_offset_to_line_col};

/// How an injected/provided key identifier resolves to a cross-file identity.
enum KeyResolution {
    /// Resolved to internal defining-site export keys (barrel chains expanded).
    Internal(Vec<ExportKey>),
    /// Imported from an npm package (external target): abstain, the provide may
    /// live inside that package's own code.
    External,
    /// A non-exported module-local symbol: identity is `(file, name)`.
    LocalOnly(Vec<ExportKey>),
}

/// Find Vue `inject(KEY)` / Svelte `getContext(KEY)` calls whose symbol KEY is
/// provided nowhere in the analyzed project.
///
/// Returns empty unless the project declares `vue` / `@vue/runtime-core` /
/// `svelte`, or if any reachable module has an unknowable-key provide (see the
/// module docs for the abstain ladder).
#[derive(Clone, Copy)]
pub(super) struct UnprovidedInjectInput<'a> {
    pub(super) graph: &'a ModuleGraph,
    pub(super) resolved_modules: &'a [ResolvedModule],
    pub(super) modules: &'a [ModuleInfo],
    pub(super) declared_deps: &'a FxHashSet<String>,
    pub(super) public_api_entry_points: &'a FxHashSet<FileId>,
    pub(super) suppressions: &'a SuppressionContext<'a>,
    pub(super) line_offsets_by_file: &'a LineOffsetsMap<'a>,
}

#[must_use]
pub fn find_unprovided_injects(input: UnprovidedInjectInput<'_>) -> Vec<UnprovidedInject> {
    if !unprovided_inject_active(input) {
        return Vec::new();
    }

    let modules_by_id: FxHashMap<FileId, &ModuleInfo> =
        input.modules.iter().map(|m| (m.file_id, m)).collect();
    let path_by_id: FxHashMap<FileId, &std::path::Path> = input
        .graph
        .modules
        .iter()
        .map(|module| (module.file_id, module.path.as_path()))
        .collect();

    let provided = build_provided_key_set(input, &modules_by_id);
    let public_export_origins =
        public_export_origin_keys(input.graph, input.public_api_entry_points);
    let ambiguity = input.graph.ambiguity_participants();

    let scan = InjectScanContext {
        input,
        modules_by_id: &modules_by_id,
        path_by_id: &path_by_id,
        provided: &provided,
        public_export_origins: &public_export_origins,
        ambiguity: &ambiguity,
    };
    collect_unprovided_inject_findings(&scan)
}

/// Whether the inject detector runs: a DI dependency is declared and no reachable
/// module has an unknowable-key (dynamic) provide (which would abstain wholesale).
fn unprovided_inject_active(input: UnprovidedInjectInput<'_>) -> bool {
    let vue =
        input.declared_deps.contains("vue") || input.declared_deps.contains("@vue/runtime-core");
    let svelte = input.declared_deps.contains("svelte");
    let angular = input.declared_deps.contains("@angular/core");
    if !vue && !svelte && !angular {
        return false;
    }
    // Dynamic-provide abstain: a single unknowable-key provide anywhere means a
    // surviving inject finding could be a false positive, so abstain wholesale.
    !input
        .modules
        .iter()
        .any(|module| module.has_dynamic_provide)
}

/// Pass 1: build the provided-key set liberally (over-crediting a provided key
/// only suppresses a finding, never creates one).
fn build_provided_key_set(
    input: UnprovidedInjectInput<'_>,
    modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
) -> FxHashSet<ExportKey> {
    let mut provided: FxHashSet<ExportKey> = FxHashSet::default();
    for resolved in input.resolved_modules {
        let Some(module) = modules_by_id.get(&resolved.file_id) else {
            continue;
        };
        if module
            .di_key_sites
            .iter()
            .all(|site| site.role != DiRole::Provide)
        {
            continue;
        }
        let local_to_export_keys = build_local_to_export_keys(resolved);
        for site in &module.di_key_sites {
            if site.role != DiRole::Provide {
                continue;
            }
            match resolve_key(
                resolved,
                input.graph,
                &local_to_export_keys,
                &site.key_local,
            ) {
                KeyResolution::Internal(keys) | KeyResolution::LocalOnly(keys) => {
                    provided.extend(keys);
                }
                KeyResolution::External => {}
            }
        }
    }
    provided
}

/// Shared read-only state threaded through the inject (Pass 2) scan.
struct InjectScanContext<'a> {
    input: UnprovidedInjectInput<'a>,
    modules_by_id: &'a FxHashMap<FileId, &'a ModuleInfo>,
    path_by_id: &'a FxHashMap<FileId, &'a std::path::Path>,
    provided: &'a FxHashSet<ExportKey>,
    public_export_origins: &'a FxHashSet<ExportKey>,
    ambiguity: &'a AmbiguityParticipants,
}

/// Pass 2: emit a finding for each inject site whose key is provided nowhere.
fn collect_unprovided_inject_findings(scan: &InjectScanContext<'_>) -> Vec<UnprovidedInject> {
    let mut findings = Vec::new();
    for resolved in scan.input.resolved_modules {
        let Some(module) = scan.modules_by_id.get(&resolved.file_id) else {
            continue;
        };
        if module
            .di_key_sites
            .iter()
            .all(|site| site.role != DiRole::Inject)
        {
            continue;
        }
        let local_to_export_keys = build_local_to_export_keys(resolved);
        for site in &module.di_key_sites {
            if site.role != DiRole::Inject {
                continue;
            }
            if let Some(finding) = evaluate_inject_site(scan, resolved, &local_to_export_keys, site)
            {
                findings.push(finding);
            }
        }
    }
    findings
}

/// Evaluate one inject site against the full abstain ladder (external key,
/// empty canonical, Angular token gate, provided-match, public-API, suppression),
/// returning a finding only when every abstain is cleared.
fn evaluate_inject_site(
    scan: &InjectScanContext<'_>,
    resolved: &ResolvedModule,
    local_to_export_keys: &FxHashMap<&str, Vec<ExportKey>>,
    site: &fallow_types::extract::DiKeySite,
) -> Option<UnprovidedInject> {
    if !inject_site_has_unprovided_key(scan, resolved, local_to_export_keys, site) {
        return None;
    }

    let (line, col) = byte_offset_to_line_col(
        scan.input.line_offsets_by_file,
        resolved.file_id,
        site.span_start,
    );
    if inject_site_suppressed(scan, resolved.file_id, line) {
        return None;
    }
    let path = scan.path_by_id.get(&resolved.file_id)?;
    Some(build_unprovided_inject(path, site, line, col))
}

fn inject_site_has_unprovided_key(
    scan: &InjectScanContext<'_>,
    resolved: &ResolvedModule,
    local_to_export_keys: &FxHashMap<&str, Vec<ExportKey>>,
    site: &fallow_types::extract::DiKeySite,
) -> bool {
    let canonical = match resolve_key(
        resolved,
        scan.input.graph,
        local_to_export_keys,
        &site.key_local,
    ) {
        // External: the provide may live inside the package; abstain.
        KeyResolution::External => return false,
        KeyResolution::Internal(keys) | KeyResolution::LocalOnly(keys) => keys,
    };
    if canonical.is_empty() {
        return false;
    }
    if canonical
        .iter()
        .any(|key| key_is_ambiguity_affected(scan, key))
    {
        return false;
    }
    // Angular InjectionToken FP gate: only a USER `InjectionToken` is in scope. A
    // class / framework token (`inject(MyService)`) is FP-prone via
    // `providedIn: 'root'` and third-party `provideX()`, so abstain unless at
    // least one canonical key is a known InjectionToken (its defining module
    // lists the export name in `injection_tokens`). Vue / Svelte sites skip it.
    if site.framework == DiFramework::Angular
        && !canonical
            .iter()
            .any(|key| is_known_injection_token(scan.modules_by_id, key))
    {
        return false;
    }
    // Matched by a provide somewhere in the project.
    if canonical.iter().any(|key| scan.provided.contains(key)) {
        return false;
    }
    // Public-API abstain: the consumer of this package provides the key.
    if canonical
        .iter()
        .any(|key| key_is_public_api(scan.input.graph, key, scan.public_export_origins))
    {
        return false;
    }

    true
}

fn key_is_ambiguity_affected(scan: &InjectScanContext<'_>, key: &ExportKey) -> bool {
    scan.ambiguity
        .contains_in_namespace(key.file_id, &key.export_name, ExportNamespace::Value)
        || matches!(
            scan.input
                .graph
                .resolve_export(key.file_id, &key.export_name, ExportNamespace::Value,),
            EffectiveExportResolution::Ambiguous
        )
}

fn inject_site_suppressed(scan: &InjectScanContext<'_>, file_id: FileId, line: u32) -> bool {
    scan.input
        .suppressions
        .is_suppressed(file_id, line, IssueKind::UnprovidedInject)
        || scan
            .input
            .suppressions
            .is_file_suppressed(file_id, IssueKind::UnprovidedInject)
}

fn build_unprovided_inject(
    path: &std::path::Path,
    site: &fallow_types::extract::DiKeySite,
    line: u32,
    col: u32,
) -> UnprovidedInject {
    UnprovidedInject {
        path: path.to_path_buf(),
        key_name: site.key_local.clone(),
        framework: framework_str(site.framework).to_string(),
        line,
        col,
    }
}

/// Resolve a key identifier to its cross-file identity, distinguishing an
/// internal symbol (resolvable to local defining sites) from a package import
/// (abstain) and a non-exported module-local symbol.
fn resolve_key(
    resolved: &ResolvedModule,
    graph: &ModuleGraph,
    local_to_export_keys: &FxHashMap<&str, Vec<ExportKey>>,
    key_local: &str,
) -> KeyResolution {
    if let Some(keys) = local_to_export_keys.get(key_local) {
        let mut canonical: Vec<ExportKey> = Vec::new();
        for key in keys {
            for origin in export_key_with_origins(graph, key) {
                if !canonical.contains(&origin) {
                    canonical.push(origin);
                }
            }
        }
        return KeyResolution::Internal(canonical);
    }

    // Not an internal import nor a local export: either an external package
    // import (abstain) or a purely-local non-exported symbol.
    let imported_external = resolved.all_resolved_imports().any(|import| {
        import.info.local_name == key_local && import.target.internal_file_id().is_none()
    });
    if imported_external {
        return KeyResolution::External;
    }
    KeyResolution::LocalOnly(vec![ExportKey::new(
        resolved.file_id,
        key_local.to_string(),
    )])
}

/// Whether the key's resolved export is part of this package's public API:
/// re-exported from, defined in, or reachable via `export *` from a non-private
/// package entry point. Such a key is provided by a downstream CONSUMER, so an
/// in-repo inject with no local provide is intentional, not dead.
///
/// The export must actually exist at `key.file_id`, so a non-exported local
/// symbol (`KeyResolution::LocalOnly` for an unexported const) is never treated
/// as public API and stays reportable.
fn key_is_public_api(
    graph: &ModuleGraph,
    key: &ExportKey,
    public_export_origins: &FxHashSet<ExportKey>,
) -> bool {
    let Some(module) = graph.modules.get(key.file_id.0 as usize) else {
        return false;
    };
    if !module
        .exports
        .iter()
        .any(|export| export_name_matches(&export.name, &key.export_name))
    {
        return false;
    }
    public_export_origins.contains(key)
}

/// Whether a canonical export key names a known Angular `InjectionToken`: the
/// key's defining module lists the export name (`.0`) in `injection_tokens`. This
/// is the load-bearing FP gate that keeps class / framework tokens out of scope.
fn is_known_injection_token(
    modules_by_id: &FxHashMap<FileId, &ModuleInfo>,
    key: &ExportKey,
) -> bool {
    modules_by_id.get(&key.file_id).is_some_and(|module| {
        module
            .injection_tokens
            .iter()
            .any(|(token_name, _interface)| *token_name == key.export_name)
    })
}

fn export_name_matches(name: &ExportName, target: &str) -> bool {
    match name {
        ExportName::Named(n) => n == target,
        ExportName::Default => target == "default",
    }
}

const fn framework_str(framework: DiFramework) -> &'static str {
    match framework {
        DiFramework::Vue => "vue",
        DiFramework::Svelte => "svelte",
        DiFramework::Angular => "angular",
    }
}