fallow-core 2.46.0

Analysis orchestration for fallow codebase intelligence (dead code, duplication, plugins, cross-reference)
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
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
use super::common::{create_config, fixture_path};

/// Create a symlink, removing any existing entry (file, directory, or stale symlink) first.
/// This makes symlink setup idempotent across repeated test runs.
fn force_symlink(target: &std::path::Path, link: &std::path::Path) {
    // Remove existing entry at the link path (regular dir, file, or broken symlink)
    if link.symlink_metadata().is_ok() {
        if link.is_dir() && !link.is_symlink() {
            let _ = std::fs::remove_dir_all(link);
        } else {
            let _ = std::fs::remove_file(link);
        }
    }
    #[cfg(unix)]
    std::os::unix::fs::symlink(target, link).expect("symlink creation should succeed");
    #[cfg(windows)]
    std::os::windows::fs::symlink_dir(target, link).expect("symlink creation should succeed");
}

#[test]
fn workspace_patterns_from_package_json() {
    let pkg: fallow_config::PackageJson =
        serde_json::from_str(r#"{"workspaces": ["packages/*", "apps/*"]}"#).unwrap();

    let patterns = pkg.workspace_patterns();
    assert_eq!(patterns, vec!["packages/*", "apps/*"]);
}

#[test]
fn workspace_patterns_yarn_format() {
    let pkg: fallow_config::PackageJson =
        serde_json::from_str(r#"{"workspaces": {"packages": ["packages/*"]}}"#).unwrap();

    let patterns = pkg.workspace_patterns();
    assert_eq!(patterns, vec!["packages/*"]);
}

// ── Workspace integration ──────────────────────────────────────

#[test]
fn workspace_project_discovers_workspace_packages() {
    let root = fixture_path("workspace-project");

    // Set up node_modules symlinks for cross-workspace resolution (like npm/pnpm install would).
    // Uses force_symlink to handle stale directories from prior runs.
    let nm = root.join("node_modules");
    let _ = std::fs::create_dir_all(nm.join("@workspace"));
    force_symlink(&root.join("packages/shared"), &nm.join("shared"));
    force_symlink(&root.join("packages/utils"), &nm.join("@workspace/utils"));

    let config = create_config(root);
    let results = fallow_core::analyze(&config).expect("analysis should succeed");

    // Workspace discovery should find files across workspace packages
    // orphan.ts should always be detected as unused since nothing imports it
    let unused_file_names: Vec<String> = results
        .unused_files
        .iter()
        .map(|f| f.path.file_name().unwrap().to_string_lossy().to_string())
        .collect();

    assert!(
        unused_file_names.contains(&"orphan.ts".to_string()),
        "orphan.ts should be detected as unused file, found: {unused_file_names:?}"
    );

    // Cross-workspace resolution via node_modules symlinks:
    // app imports `@workspace/utils/src/deep` which resolves through the symlink,
    // making deep.ts reachable. If symlinks are broken, deep.ts would be unreachable.
    assert!(
        !unused_file_names.contains(&"deep.ts".to_string()),
        "deep.ts should NOT be unused (reachable via cross-workspace import through symlink), \
         but found in unused files: {unused_file_names:?}"
    );

    // `unusedDeep` should be detected as unused export (deep.ts is reachable but
    // only `deepHelper` is imported, not `unusedDeep`)
    let unused_export_names: Vec<String> = results
        .unused_exports
        .iter()
        .map(|e| e.export_name.clone())
        .collect();
    assert!(
        unused_export_names.contains(&"unusedDeep".to_string()),
        "unusedDeep should be detected as unused export, found: {unused_export_names:?}"
    );

    // No unresolved imports — all cross-workspace imports should resolve
    assert!(
        results.unresolved_imports.is_empty(),
        "should have no unresolved imports, found: {:?}",
        results
            .unresolved_imports
            .iter()
            .map(|i| &i.specifier)
            .collect::<Vec<_>>()
    );

    // The analysis should have found issues across all workspace packages
    assert!(
        results.has_issues(),
        "workspace project should have issues detected"
    );
}

#[test]
fn project_state_stable_file_ids_by_path() {
    // FileIds should be deterministic: sorted by path, not size.
    // Running discovery twice on the same project must produce identical IDs.
    let root = fixture_path("workspace-project");
    let config = create_config(root);

    let files_a = fallow_core::discover::discover_files(&config);
    let files_b = fallow_core::discover::discover_files(&config);

    assert_eq!(files_a.len(), files_b.len());
    for (a, b) in files_a.iter().zip(files_b.iter()) {
        assert_eq!(a.id, b.id, "FileId mismatch for {:?}", a.path);
        assert_eq!(a.path, b.path);
    }

    // Files should be sorted by path (not by size)
    for window in files_a.windows(2) {
        assert!(
            window[0].path <= window[1].path,
            "Files not sorted by path: {:?} > {:?}",
            window[0].path,
            window[1].path
        );
    }
}

#[test]
fn project_state_workspace_queries() {
    use fallow_config::discover_workspaces;

    let root = fixture_path("workspace-project");
    let config = create_config(root.clone());
    let files = fallow_core::discover::discover_files(&config);
    let workspaces = discover_workspaces(&root);
    let project = fallow_core::project::ProjectState::new(files, workspaces);

    // Should find all three workspace packages
    assert!(project.workspace_by_name("app").is_some());
    assert!(project.workspace_by_name("shared").is_some());
    assert!(project.workspace_by_name("@workspace/utils").is_some());
    assert!(project.workspace_by_name("nonexistent").is_none());

    // Files should be assignable to workspaces
    let app_ws = project.workspace_by_name("app").unwrap();
    let app_files = project.files_in_workspace(app_ws);
    assert!(
        !app_files.is_empty(),
        "app workspace should have at least one file"
    );

    // All app files should be under the app workspace root
    for fid in &app_files {
        if let Some(file) = project.file_by_id(*fid) {
            assert!(
                file.path.starts_with(&app_ws.root),
                "File {:?} should be under app workspace root {:?}",
                file.path,
                app_ws.root
            );
        }
    }
}

// ── Workspace exports map resolution ───────────────────────────

#[test]
fn workspace_exports_map_resolves_subpath_imports() {
    let root = fixture_path("workspace-exports-map");

    // Set up node_modules symlinks for cross-workspace resolution.
    // Uses force_symlink to handle stale directories from prior runs.
    let nm = root.join("node_modules");
    let _ = std::fs::create_dir_all(nm.join("@workspace"));
    force_symlink(&root.join("packages/ui"), &nm.join("@workspace/ui"));

    let config = create_config(root);
    let results = fallow_core::analyze(&config).expect("analysis should succeed");

    let unused_file_names: Vec<String> = results
        .unused_files
        .iter()
        .map(|f| f.path.file_name().unwrap().to_string_lossy().to_string())
        .collect();

    // orphan.ts is not exported via exports map and not imported — should be unused
    assert!(
        unused_file_names.contains(&"orphan.ts".to_string()),
        "orphan.ts should be detected as unused file, found: {unused_file_names:?}"
    );

    // utils.ts is imported via `@workspace/ui/utils` through exports map → should NOT be unused
    assert!(
        !unused_file_names.contains(&"utils.ts".to_string()),
        "utils.ts should be reachable via exports map subpath import, unused: {unused_file_names:?}"
    );

    // helpers.ts (source) should be reachable via exports map pointing to dist/helpers.js
    // fallow should map dist/helpers.js back to src/helpers.ts
    assert!(
        !unused_file_names.contains(&"helpers.ts".to_string()),
        "helpers.ts should be reachable via dist→src fallback from exports map, unused: {unused_file_names:?}"
    );

    // internal.ts is imported by utils.ts, so it should be reachable
    assert!(
        !unused_file_names.contains(&"internal.ts".to_string()),
        "internal.ts should be reachable via import from utils.ts, unused: {unused_file_names:?}"
    );

    // Unused exports on non-entry-point files should still be detected.
    // internal.ts is NOT an entry point (not in exports map) but is imported
    // by utils.ts — so its unused exports should be flagged.
    let unused_export_names: Vec<&str> = results
        .unused_exports
        .iter()
        .map(|e| e.export_name.as_str())
        .collect();

    assert!(
        unused_export_names.contains(&"unusedInternal"),
        "unusedInternal should be unused (internal.ts is not an entry point), found: {unused_export_names:?}"
    );

    // Used exports should NOT be flagged
    assert!(
        !unused_export_names.contains(&"internalHelper"),
        "internalHelper should be used (imported by utils.ts)"
    );

    // No unresolved imports — exports map subpaths should all resolve
    assert!(
        results.unresolved_imports.is_empty(),
        "should have no unresolved imports, found: {:?}",
        results
            .unresolved_imports
            .iter()
            .map(|i| &i.specifier)
            .collect::<Vec<_>>()
    );
}

// ── Workspace nested exports map ──────────────────────────────

#[test]
fn workspace_nested_exports_resolves_dist_to_source() {
    let root = fixture_path("workspace-nested-exports");

    // Set up node_modules symlinks for cross-workspace resolution.
    // Uses force_symlink to handle stale directories from prior runs.
    let nm = root.join("node_modules");
    let _ = std::fs::create_dir_all(nm.join("@workspace"));
    force_symlink(&root.join("packages/ui"), &nm.join("@workspace/ui"));

    let config = create_config(root);
    let results = fallow_core::analyze(&config).expect("analysis should succeed");

    let unused_file_names: Vec<String> = results
        .unused_files
        .iter()
        .map(|f| {
            f.path
                .to_string_lossy()
                .replace('\\', "/")
                .rsplit('/')
                .next()
                .unwrap_or_default()
                .to_string()
        })
        .collect();

    // Source files reachable via exports map dist→src fallback should NOT be unused
    assert!(
        !unused_file_names.contains(&"index.ts".to_string()),
        "index.ts should be reachable via exports map root entry, unused: {unused_file_names:?}"
    );
    assert!(
        !unused_file_names.contains(&"utils.ts".to_string()),
        "utils.ts should be reachable via dist/esm/utils.mjs→src/utils.ts fallback, \
         unused: {unused_file_names:?}"
    );
    assert!(
        !unused_file_names.contains(&"Button.ts".to_string()),
        "Button.ts should be reachable via dist/esm/components/Button.mjs→src/components/Button.ts \
         fallback, unused: {unused_file_names:?}"
    );

    // Unused exports should still be detected on reachable files
    let unused_export_names: Vec<&str> = results
        .unused_exports
        .iter()
        .map(|e| e.export_name.as_str())
        .collect();

    // unusedComponent is on index.ts which is the root entry point ("." in exports map),
    // so its exports are treated as public API and not flagged as unused
    assert!(
        !unused_export_names.contains(&"unusedComponent"),
        "unusedComponent should NOT be flagged (index.ts is an entry point)"
    );

    // Non-entry-point files resolved via dist→src fallback should still have unused exports flagged
    assert!(
        unused_export_names.contains(&"unusedUtil"),
        "unusedUtil should be unused (utils.ts export not imported by app), \
         found: {unused_export_names:?}"
    );
    assert!(
        unused_export_names.contains(&"unusedButtonHelper"),
        "unusedButtonHelper should be unused (Button.ts export not imported by app), \
         found: {unused_export_names:?}"
    );

    // Used exports should NOT be flagged
    assert!(
        !unused_export_names.contains(&"Card"),
        "Card should be used (imported by app)"
    );
    assert!(
        !unused_export_names.contains(&"formatColor"),
        "formatColor should be used (imported by app)"
    );
    assert!(
        !unused_export_names.contains(&"Button"),
        "Button should be used (imported by app)"
    );

    // No unresolved imports — nested exports map subpaths should all resolve
    assert!(
        results.unresolved_imports.is_empty(),
        "should have no unresolved imports, found: {:?}",
        results
            .unresolved_imports
            .iter()
            .map(|i| &i.specifier)
            .collect::<Vec<_>>()
    );
}

// ── TypeScript project references ──────────────────────────────

#[test]
fn tsconfig_references_discovers_workspaces() {
    use fallow_config::discover_workspaces;

    let root = fixture_path("tsconfig-references");
    let workspaces = discover_workspaces(&root);

    // Should discover both referenced projects from tsconfig.json references
    assert!(
        workspaces.len() >= 2,
        "Expected at least 2 workspaces from tsconfig references, got: {workspaces:?}"
    );
    assert!(
        workspaces.iter().any(|ws| ws.name == "@project/core"),
        "Should discover @project/core from package.json name: {workspaces:?}"
    );
    assert!(
        workspaces.iter().any(|ws| ws.name == "ui"),
        "Should discover ui from directory name (no package.json): {workspaces:?}"
    );
}

#[test]
fn tsconfig_references_analysis_detects_unused() {
    let root = fixture_path("tsconfig-references");
    let config = create_config(root);
    let results = fallow_core::analyze(&config).expect("analysis should succeed");

    let unused_file_names: Vec<String> = results
        .unused_files
        .iter()
        .map(|f| f.path.file_name().unwrap().to_string_lossy().to_string())
        .collect();

    // unused.ts in core and orphan.ts in ui should be detected as unused
    assert!(
        unused_file_names.contains(&"unused.ts".to_string()),
        "unused.ts should be detected as unused file: {unused_file_names:?}"
    );
    assert!(
        unused_file_names.contains(&"orphan.ts".to_string()),
        "orphan.ts should be detected as unused file: {unused_file_names:?}"
    );

    // index.ts files should NOT be unused (core/index.ts is imported by ui/index.ts)
    assert!(
        !unused_file_names.contains(&"index.ts".to_string()),
        "index.ts should not be unused: {unused_file_names:?}"
    );
}

// ── Shallow nested package fallback ─────────────────────────────

#[test]
fn shallow_nested_package_scripts_become_entry_points_without_workspace_config() {
    let root = fixture_path("shallow-package-scripts");
    let config = create_config(root);
    let results = fallow_core::analyze(&config).expect("analysis should succeed");

    let unused_file_names: Vec<String> = results
        .unused_files
        .iter()
        .map(|f| {
            f.path
                .to_string_lossy()
                .replace('\\', "/")
                .rsplit('/')
                .next()
                .unwrap_or_default()
                .to_string()
        })
        .collect();

    assert!(
        !unused_file_names.contains(&"generate.mjs".to_string()),
        "generate.mjs should be treated as a package.json script entry point: {unused_file_names:?}"
    );
    assert!(
        !unused_file_names.contains(&"helper.mjs".to_string()),
        "helper.mjs should be reachable from generate.mjs: {unused_file_names:?}"
    );
    assert!(
        unused_file_names.contains(&"orphan.mjs".to_string()),
        "orphan.mjs should remain unused: {unused_file_names:?}"
    );
}