deps-cli 2.0.0

CLI for running deps-lsp's dependency-health checks in CI, pre-commit, and shell workflows
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
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
//! Integration tests for the `check` pipeline: `walk` → `report::check_manifest` →
//! `format`/`exit`, run against real fixture manifests and real ecosystem registrations
//! (via `deps_engine::setup::register_ecosystems`) — never a fake `Ecosystem`, since none of
//! the ecosystem crates expose a public way to inject a mock registry from outside their own
//! crate (their `with_base_for_test`-style constructors are `#[cfg(test)] pub(crate)`).
//!
//! Every scenario here runs `--offline` (FR-013): [`deps_core::HttpCache::set_offline`]
//! intercepts before any real HTTP call reaches the network, so these tests are fast and
//! deterministic without needing a mock registry server. This also doubles as the SC-004
//! network-isolation test — see `test_offline_run_completes_without_network_access`, and its
//! doc comment for exactly what is (and is not) proved without a request-counting test
//! double (spec 062 review S6).
//!
//! // TODO(critic): FR-005 automated parity test vs deps-lsp handlers::diagnostics (T025)
//!
//! A live cross-tool parity check against `deps-lsp`'s own diagnostics path (FR-005/SC-001)
//! is deferred to manual verification (`.claude/rules/continuous-improvement.md`) and a
//! follow-up automated test that drives a real `deps-lsp` `ServerState` — see this PR's
//! handoff notes for why: `deps-lsp`'s `generate_diagnostics_internal` is `pub(crate)` and
//! `handle_diagnostics` needs a live `tower_lsp_server::Client`, both nontrivial to construct
//! from an external crate's test suite. **Structural non-drift is not, in fact, guaranteed
//! "today" without qualification** (correction, spec 062 review C2): both adapters call the
//! same `deps_engine::classify::*` functions and the identical `Ecosystem::generate_diagnostics`,
//! but C2 proved the *inputs* fed into that shared call can still diverge per adapter (this
//! crate was dropping `fetch_result.licenses` entirely before that fix landed) — sharing the
//! classification function does not, by itself, prove every adapter assembles its inputs
//! identically. The live parity test above is what would actually close that gap; until it
//! exists, non-drift rests on manual review of each `VersionData` assembly site matching.

// `allow-expect-in-tests` only recognizes `#[test]` bodies, not the plain helper functions
// (`offline_context`/`run_pipeline`) every test here calls.
#![allow(clippy::expect_used)]

use deps_cli::exit::{EXIT_CLEAN, ExecutionOutcome, exit_code};
use deps_cli::report::{CheckContext, CheckReport, FailOnPolicy, check_manifest};
use deps_cli::{format, walk};
use deps_core::osv::OsvClient;
use deps_core::policy_config::{LicensePolicyConfig, PolicyConfig};
use deps_core::{EcosystemRegistry, HttpCache, NetworkMode};
use deps_engine::setup::{EcosystemRuntime, register_ecosystems};
use std::sync::Arc;
use std::time::Duration;

/// Builds a real, fully-registered [`EcosystemRegistry`] plus an offline [`CheckContext`] —
/// the same construction `main.rs` performs, minus config-file loading.
fn offline_context() -> (EcosystemRegistry, CheckContext) {
    let policy = PolicyConfig::default();
    let runtime = EcosystemRuntime::from_policy(&policy);
    let cache = Arc::new(HttpCache::with_policy(Arc::clone(&runtime.policy)));
    cache.set_offline(NetworkMode::Offline);
    assert!(
        cache.is_offline(),
        "test setup bug: HttpCache must actually be offline before this helper is trusted"
    );
    let registry = EcosystemRegistry::new();
    let _ = register_ecosystems(&registry, Arc::clone(&cache), &runtime);

    let mut policy = policy;
    policy.network.offline = true;

    let ctx = CheckContext {
        cache: Arc::clone(&cache),
        osv: Arc::new(OsvClient::new(Arc::clone(&cache))),
        deps_dev: Arc::new(deps_core::DepsDevClient::new(Arc::clone(&cache))),
        lockfile_cache: Arc::new(deps_core::lockfile::LockFileCache::new()),
        policy,
    };
    (registry, ctx)
}

/// Runs the full `walk` → `check_manifest` pipeline against `dir` and returns the assembled
/// report plus whether any manifest's registry fetch was reported unreachable.
async fn run_pipeline(dir: &std::path::Path) -> (CheckReport, bool) {
    let (registry, ctx) = offline_context();
    let outcome = walk::walk(
        &[dir.to_path_buf()],
        &registry,
        walk::GitignorePolicy::Ignore,
        walk::SymlinkPolicy::Skip,
    );
    assert!(!outcome.truncated);

    let mut findings = Vec::new();
    let mut had_execution_error = false;
    for manifest in outcome.manifests() {
        let content = deps_core::fs_probe::read_to_string_capped(&manifest.path, 10_000_000)
            .expect("read fixture manifest")
            .expect("fixture manifest under size cap");
        // Review finding M2: mirrors main.rs's fix — `uri_path` (not `path`) drives lockfile
        // lookup, so this harness exercises the same correct wiring `check` itself uses.
        let result = check_manifest(
            &manifest.ecosystem,
            &manifest.uri_path,
            &manifest.display_path,
            &content,
            &ctx,
        )
        .await
        .expect("check_manifest must not fail for a well-formed fixture");
        had_execution_error |= result.registry_unreachable || result.license_fetch_incomplete;
        findings.extend(result.findings);
    }
    (CheckReport { findings }, had_execution_error)
}

/// SC-004's network-isolation guarantee, composed from two facts rather than one black-box
/// timing heuristic (spec 062 review S6):
///
/// 1. `offline_context`'s own assertion proves *this crate's* wiring actually calls
///    `HttpCache::set_offline(NetworkMode::Offline)` — the exact class of bug this PR's own history hit once
///    (the original implementation gated only the OSV scan and forgot the registry fetch
///    path entirely).
/// 2. `HttpCache::ensure_online` — the shared gate every one of `deps-core`'s 4 send sites
///    checks before opening a socket — is deps-core's own, separately audited invariant (spec
///    062 security review F1's audit verified this empirically with a canary token), not
///    something this crate's test suite re-proves from scratch.
///
/// Together these give a real, non-heuristic proof, but neither one is a request counter: no
/// `Registry` implementation is injectable from outside its own ecosystem crate (see this
/// file's module doc), so there is no way to assert "zero calls reached a `Registry` method"
/// from here — only "zero calls reached the network", which is the property SC-004 actually
/// cares about. The bounded timeout below is a belt-and-suspenders regression signal on top of
/// those two proofs, not the proof itself: a real (accidental) network attempt against an
/// unreachable/slow host would hang well past it.
#[tokio::test]
async fn test_offline_run_completes_without_network_access() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::fs::write(
        dir.path().join("Cargo.toml"),
        "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \"1.0\"\n",
    )
    .expect("write fixture manifest");

    let (report, _had_error) =
        tokio::time::timeout(Duration::from_secs(10), run_pipeline(dir.path()))
            .await
            .expect("offline run must not block on network I/O");

    // `serde` has no lock-file-resolved version and offline mode never fetches, so it
    // surfaces as an unresolved-lookup finding rather than a crash or a silently empty report.
    assert!(
        !report.findings.is_empty(),
        "offline mode must still report on what it can determine"
    );
}

#[tokio::test]
async fn test_empty_directory_produces_no_findings_and_clean_exit() {
    let dir = tempfile::tempdir().expect("create temp dir");
    let (report, had_error) = run_pipeline(dir.path()).await;
    assert!(report.findings.is_empty());
    assert!(!had_error);
    assert_eq!(
        exit_code(
            &report,
            &FailOnPolicy::default_categories(),
            ExecutionOutcome::from_had_execution_error(had_error)
        ),
        EXIT_CLEAN
    );
}

#[tokio::test]
async fn test_multi_ecosystem_fixture_tree_discovers_both_manifests() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::fs::write(
        dir.path().join("Cargo.toml"),
        "[package]\nname = \"fixture\"\n\n[dependencies]\nserde = \"1.0\"\n",
    )
    .expect("write cargo manifest");
    std::fs::write(
        dir.path().join("package.json"),
        r#"{"name":"fixture","dependencies":{"left-pad":"1.0.0"}}"#,
    )
    .expect("write npm manifest");

    let (report, _had_error) = run_pipeline(dir.path()).await;
    let ecosystems: std::collections::HashSet<_> =
        report.findings.iter().map(|f| f.ecosystem).collect();
    assert!(ecosystems.contains(&deps_core::EcosystemId::Cargo));
    assert!(ecosystems.contains(&deps_core::EcosystemId::Npm));
}

#[tokio::test]
async fn test_table_and_json_formatters_render_the_same_pipeline_output() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::fs::write(
        dir.path().join("Cargo.toml"),
        "[package]\nname = \"fixture\"\n\n[dependencies]\nserde = \"1.0\"\n",
    )
    .expect("write fixture manifest");

    let (report, _had_error) = run_pipeline(dir.path()).await;

    let table = format::table::render(&report);
    assert!(table.contains("Cargo.toml"));

    let json = format::json::render(&report).expect("json render must succeed");
    let document: format::json::ReportDocument =
        serde_json::from_str(&json).expect("json must round-trip");
    assert_eq!(document.schema_version, format::json::SCHEMA_VERSION);
    assert_eq!(document.findings.len(), report.findings.len());
}

#[tokio::test]
async fn test_sarif_formatter_renders_the_same_pipeline_output() {
    // Directory name needs percent-encoding (`#` reads as a URI fragment separator, space is
    // invalid in a bare URI-reference) — the repro this test guards against (spec 062 review S2/B3).
    let dir = tempfile::tempdir().expect("create temp dir");
    let manifest_dir = dir.path().join("weird dir#name");
    std::fs::create_dir(&manifest_dir).expect("create nested fixture dir");
    std::fs::write(
        manifest_dir.join("Cargo.toml"),
        "[package]\nname = \"fixture\"\n\n[dependencies]\nserde = \"1.0\"\n",
    )
    .expect("write fixture manifest");

    let (report, _had_error) = run_pipeline(dir.path()).await;
    assert!(
        !report.findings.is_empty(),
        "serde 1.0 must produce at least one finding"
    );

    let sarif = format::sarif::to_sarif(&report);
    let results = sarif.runs[0]
        .results
        .as_ref()
        .expect("results must be present");
    assert_eq!(results.len(), report.findings.len());

    for result in results {
        let uri = result
            .locations
            .as_ref()
            .expect("locations must be present")[0]
            .physical_location
            .as_ref()
            .expect("physicalLocation must be present")
            .artifact_location
            .as_ref()
            .expect("artifactLocation must be present")
            .uri
            .as_ref()
            .expect("uri must be present");
        assert!(
            !uri.contains('#'),
            "a literal '#' in {uri:?} would be read as a URI fragment separator"
        );
        assert!(
            !uri.contains(' '),
            "a literal space in {uri:?} is not a valid URI-reference"
        );
        assert!(
            !uri.contains('\\'),
            "{uri:?} must use '/' separators, not the platform's own (possibly '\\\\') display form"
        );
        assert!(
            !std::path::Path::new(uri).is_absolute(),
            "{uri:?} must stay relative to the walked root, matching table/json's own display_path"
        );
    }

    let json = format::sarif::render(&report).expect("sarif render must succeed");
    let parsed: serde_json::Value = serde_json::from_str(&json).expect("sarif must round-trip");
    assert_eq!(parsed["version"], "2.1.0");
}

#[tokio::test]
async fn test_walk_paths_default_to_current_directory_semantics_via_single_file() {
    // Exercises the "explicit manifest path, not a directory" branch of `walk::walk` end to
    // end (FR-002's routing applies identically either way).
    let dir = tempfile::tempdir().expect("create temp dir");
    let manifest = dir.path().join("Cargo.toml");
    std::fs::write(&manifest, "[package]\nname = \"fixture\"\n").expect("write fixture manifest");

    let (registry, ctx) = offline_context();
    let outcome = walk::walk(
        std::slice::from_ref(&manifest),
        &registry,
        walk::GitignorePolicy::Ignore,
        walk::SymlinkPolicy::Skip,
    );
    assert_eq!(outcome.manifests().len(), 1);

    let content = deps_core::fs_probe::read_to_string_capped(&manifest, 10_000_000)
        .expect("read manifest")
        .expect("under size cap");
    let result = check_manifest(
        &outcome.manifests()[0].ecosystem,
        &manifest,
        &manifest,
        &content,
        &ctx,
    )
    .await
    .expect("check_manifest must succeed");
    // `Cargo.toml` with no `[dependencies]` produces no findings at all.
    assert!(result.findings.is_empty());
}

#[tokio::test]
async fn test_sarif_formatter_relativizes_an_absolute_single_file_path() {
    // An explicit file path routes through `walk::walk`'s `root.is_file()` branch, which passes
    // the absolute path through as `display_path` unchanged (spec 062 review R1: `manifest_uri`
    // must not leak it into `artifactLocation.uri` as-is).
    let dir = tempfile::tempdir().expect("create temp dir");
    let manifest = dir.path().join("Cargo.toml");
    assert!(
        manifest.is_absolute(),
        "test setup bug: fixture path must be absolute"
    );
    std::fs::write(
        &manifest,
        "[package]\nname = \"fixture\"\n\n[dependencies]\nserde = \"1.0\"\n",
    )
    .expect("write fixture manifest");

    let (registry, ctx) = offline_context();
    let outcome = walk::walk(
        std::slice::from_ref(&manifest),
        &registry,
        walk::GitignorePolicy::Ignore,
        walk::SymlinkPolicy::Skip,
    );
    assert_eq!(outcome.manifests().len(), 1);
    assert!(
        outcome.manifests()[0].display_path.is_absolute(),
        "test setup bug: this must exercise the absolute-display_path branch"
    );

    let content = deps_core::fs_probe::read_to_string_capped(&manifest, 10_000_000)
        .expect("read manifest")
        .expect("under size cap");
    let result = check_manifest(
        &outcome.manifests()[0].ecosystem,
        &manifest,
        &manifest,
        &content,
        &ctx,
    )
    .await
    .expect("check_manifest must succeed");
    assert!(
        !result.findings.is_empty(),
        "serde 1.0 must produce at least one finding"
    );

    let report = CheckReport {
        findings: result.findings,
    };
    let sarif = format::sarif::to_sarif(&report);
    let results = sarif.runs[0]
        .results
        .as_ref()
        .expect("results must be present");
    for result in results {
        let uri = result
            .locations
            .as_ref()
            .expect("locations must be present")[0]
            .physical_location
            .as_ref()
            .expect("physicalLocation must be present")
            .artifact_location
            .as_ref()
            .expect("artifactLocation must be present")
            .uri
            .as_ref()
            .expect("uri must be present");
        assert!(
            !std::path::Path::new(uri).is_absolute(),
            "{uri:?} must not stay absolute — it leaks local machine path structure into a \
             document meant to be uploaded to GitHub code scanning"
        );
    }
}

/// Reviewer follow-up #5: exercises the *real* wiring in `main.rs` (warnings →
/// `had_execution_error` → `exit_code`) via the actual built binary, not `run_pipeline`'s
/// simplified reimplementation above — `run_pipeline` hardcodes `walk::walk(..., false)` and
/// never touches `main.rs::run_check`'s own warning/exit-code logic, so a regression there
/// could pass every other test in this file while silently reopening a fail-open gap.
#[test]
fn test_respect_gitignore_flag_reaches_the_real_exit_code_wiring() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::fs::create_dir(dir.path().join(".git")).expect("create .git marker");
    std::fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
    std::fs::write(
        dir.path().join("Cargo.toml"),
        "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
    )
    .expect("write fixture manifest");

    let exe = env!("CARGO_BIN_EXE_deps-cli");
    let output = std::process::Command::new(exe)
        .args(["check", "--offline", "--respect-gitignore"])
        .arg(dir.path())
        .output()
        .expect("run the real deps-cli binary");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert_eq!(
        output.status.code(),
        Some(2),
        "a manifest excluded by --respect-gitignore must exit 2 (execution error), not 0 — \
         stderr: {stderr}"
    );
    assert!(
        stderr.contains("excluded from the scan"),
        "must warn about the excluded manifest, stderr: {stderr}"
    );
}

/// Companion to the above: the same fixture under the default (`respect_gitignore: false`)
/// mode must find the manifest and exit clean through the real binary too.
#[test]
fn test_default_mode_ignores_gitignore_through_the_real_binary_and_exits_clean() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::fs::create_dir(dir.path().join(".git")).expect("create .git marker");
    std::fs::write(dir.path().join(".gitignore"), "Cargo.toml\n").expect("write gitignore");
    std::fs::write(
        dir.path().join("Cargo.toml"),
        "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
    )
    .expect("write fixture manifest");

    let exe = env!("CARGO_BIN_EXE_deps-cli");
    let output = std::process::Command::new(exe)
        .args(["check", "--offline"])
        .arg(dir.path())
        .output()
        .expect("run the real deps-cli binary");

    assert_eq!(
        output.status.code(),
        Some(0),
        "stderr: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

/// Issue #1124's own repro, exercised through the real binary (mirroring reviewer follow-up
/// #5's rationale above for `--respect-gitignore`): a manifest replaced by a broken symlink
/// must not be silently dropped from the scan, and the warning must distinguish this case
/// (possible tampering) from an ordinary excluded manifest.
#[cfg(unix)]
#[test]
fn test_broken_symlink_manifest_reaches_the_real_exit_code_wiring() {
    let dir = tempfile::tempdir().expect("create temp dir");
    // M1 (critic follow-up): a second *recognized* manifest, so `manifests.is_empty()` isn't
    // an independent, overlapping reason for a non-zero exit — the broken symlink must be the
    // only thing driving `had_execution_error` here. `--fail-on vulnerable` matches the
    // issue's own literal repro command.
    std::fs::write(
        dir.path().join("package.json"),
        "{\"name\": \"fixture\", \"version\": \"0.1.0\"}\n",
    )
    .expect("write a second, recognized manifest");
    std::os::unix::fs::symlink(
        dir.path().join("does-not-exist.toml"),
        dir.path().join("Cargo.toml"),
    )
    .expect("create broken symlink replacing Cargo.toml");

    let exe = env!("CARGO_BIN_EXE_deps-cli");
    let output = std::process::Command::new(exe)
        .args(["check", "--offline", "--fail-on", "vulnerable"])
        .arg(dir.path())
        .output()
        .expect("run the real deps-cli binary");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert_eq!(
        output.status.code(),
        Some(2),
        "a manifest replaced by a broken symlink must exit 2 (execution error), not 0, even \
         though a second manifest was found and scanned cleanly — stderr: {stderr}"
    );
    assert!(
        stderr.contains("could not be resolved"),
        "must warn with the broken-symlink-specific message, not the generic excluded-manifest \
         one, stderr: {stderr}"
    );
    assert!(
        !stderr.contains("no manifests were discovered"),
        "the second manifest must have been found — this exit code must come from the broken \
         symlink alone, stderr: {stderr}"
    );
}

/// Companion to the above: the same fixture under `--follow-symlinks` must still report the
/// broken symlink (resolving it is impossible by definition), not treat the flag as silencing
/// the warning.
#[cfg(unix)]
#[test]
fn test_broken_symlink_manifest_still_reported_under_follow_symlinks() {
    let dir = tempfile::tempdir().expect("create temp dir");
    std::os::unix::fs::symlink(
        dir.path().join("does-not-exist.toml"),
        dir.path().join("Cargo.toml"),
    )
    .expect("create broken symlink replacing Cargo.toml");

    let exe = env!("CARGO_BIN_EXE_deps-cli");
    let output = std::process::Command::new(exe)
        .args(["check", "--offline", "--follow-symlinks"])
        .arg(dir.path())
        .output()
        .expect("run the real deps-cli binary");

    let stderr = String::from_utf8_lossy(&output.stderr);
    assert_eq!(
        output.status.code(),
        Some(2),
        "--follow-symlinks must not turn a broken symlink into a clean exit — stderr: {stderr}"
    );
    assert!(stderr.contains("could not be resolved"), "stderr: {stderr}");
}

/// Review finding M2: a manifest symlinked from directory A (which has its own `Cargo.lock`)
/// to a target manifest in directory B (which has none) must find A's lockfile during
/// `check_manifest`'s lockfile/in-use-version discovery, not B's absence of one — matching
/// US-002's own shared-manifest-symlinked-into-multiple-package-directories scenario, where
/// each symlinked location has its own adjacent lockfile.
///
/// Exercises the exact mechanism `check_manifest` -> `load_resolved_versions` relies on
/// (`Ecosystem::lockfile_provider().locate_lockfile`) directly against the URIs
/// [`DiscoveredManifest::uri_path`]/[`DiscoveredManifest::path`] actually produce, rather than
/// observing an indirect effect through findings (which would need a populated/mocked registry
/// to show a version difference).
#[cfg(unix)]
#[tokio::test]
async fn test_follow_symlinks_lockfile_lookup_uses_symlinks_directory_not_targets() {
    // A and B are both subdirectories of one walked root — the symlink's target must stay
    // inside the walked root (FR-004) for `--follow-symlinks` to resolve and route it at all;
    // A/B being two independent, unrelated temp directories would make the target a root
    // escape instead, an unrelated scenario this test isn't exercising.
    let root = tempfile::tempdir().expect("create walked root");
    let dir_a = root.path().join("a");
    let dir_b = root.path().join("b");
    std::fs::create_dir(&dir_a).expect("mkdir a");
    std::fs::create_dir(&dir_b).expect("mkdir b");

    std::fs::write(
        dir_b.join("manifest-data"),
        "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[dependencies]\nonce_cell = \"1\"\n",
    )
    .expect("write target manifest in B");
    std::fs::write(
        dir_a.join("Cargo.lock"),
        "version = 4\n\n[[package]]\nname = \"once_cell\"\nversion = \"1.0.0\"\nsource = \"registry+https://github.com/rust-lang/crates.io-index\"\n",
    )
    .expect("write A's Cargo.lock");
    std::os::unix::fs::symlink(dir_b.join("manifest-data"), dir_a.join("Cargo.toml"))
        .expect("symlink A/Cargo.toml -> B/Cargo.toml");

    let (registry, ctx) = offline_context();
    let outcome = walk::walk(
        &[root.path().to_path_buf()],
        &registry,
        walk::GitignorePolicy::Ignore,
        walk::SymlinkPolicy::Follow,
    );
    assert_eq!(
        outcome.manifests().len(),
        1,
        "ignored_manifests: {:?}, walk_errors: {:?}",
        outcome.ignored_manifests(),
        outcome.walk_errors()
    );
    let manifest = &outcome.manifests()[0];

    // Sanity check on the fields M2's fix relies on: `path` (content read, canonicalized by
    // the C1/S3 containment check) resolves to B's real file; `uri_path` (lockfile lookup)
    // stays A's encountered symlink path, not canonicalized — matching `route_path`'s
    // semantics (see `route_file`'s doc).
    assert_eq!(
        manifest.path.canonicalize().expect("canonicalize path"),
        dir_b
            .join("manifest-data")
            .canonicalize()
            .expect("canonicalize B/Cargo.toml")
    );
    assert_eq!(manifest.uri_path, dir_a.join("Cargo.toml"));

    let lockfile_provider = manifest
        .ecosystem
        .lockfile_provider()
        .expect("cargo ecosystem must have a lock file provider");

    let uri_path_uri = url::Url::from_file_path(&manifest.uri_path).expect("uri_path to file uri");
    let path_uri = url::Url::from_file_path(&manifest.path).expect("path to file uri");

    assert_eq!(
        lockfile_provider.locate_lockfile(&uri_path_uri),
        Some(dir_a.join("Cargo.lock")),
        "lockfile lookup driven by uri_path must find A's Cargo.lock"
    );
    assert_eq!(
        lockfile_provider.locate_lockfile(&path_uri),
        None,
        "lockfile lookup driven by the resolved target path (B) must find nothing — B has no \
         Cargo.lock; if this were Some, check_manifest would silently anchor lockfile lookup \
         at the wrong directory"
    );

    // End-to-end: exercise the exact call `main.rs` makes (`manifest.uri_path`, per M2's fix)
    // and confirm A's lockfile actually gets parsed into the cache — the load-bearing
    // assertion that would catch a regression reverting `main.rs`'s argument choice, not just
    // the underlying `locate_lockfile` mechanism checked above.
    let content = std::fs::read_to_string(&manifest.path).expect("read manifest content");
    check_manifest(
        &manifest.ecosystem,
        &manifest.uri_path,
        &manifest.display_path,
        &content,
        &ctx,
    )
    .await
    .expect("check_manifest must succeed with the correct (uri_path) wiring");
    assert_eq!(
        ctx.lockfile_cache.len(),
        1,
        "check_manifest called with uri_path must have found and cached A's Cargo.lock"
    );

    // Simulates the M2 bug (passing the resolved target path instead of uri_path) against a
    // fresh cache — must find and cache nothing, since B has no Cargo.lock.
    let buggy_ctx = CheckContext {
        cache: Arc::clone(&ctx.cache),
        osv: Arc::clone(&ctx.osv),
        deps_dev: Arc::clone(&ctx.deps_dev),
        lockfile_cache: Arc::new(deps_core::lockfile::LockFileCache::new()),
        policy: ctx.policy.clone(),
    };
    check_manifest(
        &manifest.ecosystem,
        &manifest.path,
        &manifest.display_path,
        &content,
        &buggy_ctx,
    )
    .await
    .expect("check_manifest must still succeed (absence of a lock file is not an error)");
    assert_eq!(
        buggy_ctx.lockfile_cache.len(),
        0,
        "check_manifest called with the resolved target path (the bug M2 fixes) must find no \
         lockfile at all, proving the two wirings genuinely diverge"
    );
}

/// Builds a real, fully-registered [`EcosystemRegistry`] plus a *non*-offline
/// [`CheckContext`] under `license_policy` — mirrors [`offline_context`] but leaves
/// `HttpCache` free to make real requests, for the tier-3 license-prefetch parity tests
/// below (issue #1133).
fn live_context(license_policy: LicensePolicyConfig) -> (EcosystemRegistry, CheckContext) {
    let policy = PolicyConfig {
        license_policy,
        ..PolicyConfig::default()
    };
    let runtime = EcosystemRuntime::from_policy(&policy);
    let cache = Arc::new(HttpCache::with_policy(Arc::clone(&runtime.policy)));
    let registry = EcosystemRegistry::new();
    let _ = register_ecosystems(&registry, Arc::clone(&cache), &runtime);

    let ctx = CheckContext {
        cache: Arc::clone(&cache),
        osv: Arc::new(OsvClient::new(Arc::clone(&cache))),
        deps_dev: Arc::new(deps_core::DepsDevClient::new(Arc::clone(&cache))),
        lockfile_cache: Arc::new(deps_core::lockfile::LockFileCache::new()),
        policy,
    };
    (registry, ctx)
}

/// Issue #1133 parity: `deps-cli check` must reach the same license-policy verdict as
/// `deps-lsp` for the four tier-3 ecosystems (Dart, Swift, Gradle, Deno), whose license
/// needs a dedicated [`deps_core::Ecosystem::fetch_license`] call beyond the registry's
/// hot-path version-list response. Before this fix, `deps-cli` silently never called it
/// (spec 062 deviation #2), so a `license_policy` never fired for these ecosystems — a
/// dependency with a real, denied license would pass `check` cleanly instead of failing it.
///
/// Each test below fetches the same dependency/version `deps-lsp`'s own
/// `document::osv_scan::tests::license_prefetch_tests` live tests use (see
/// `crates/deps-lsp/src/document/osv_scan.rs`), so a regression that breaks tier-3 wiring in
/// one adapter but not the other would produce a mismatch across the two test suites even
/// though `tower_lsp_server`'s `Client` requirement keeps them from sharing one test
/// function (see this file's module doc). The manifest requirement syntax differs from
/// `deps-lsp`'s fixture where needed (an exact/bare pin instead of a caret range) — unlike
/// `deps-lsp`'s test, which injects `resolved_versions` directly into `DocumentState`,
/// `check_manifest`'s public API has no equivalent shortcut and only ever sees a real lock
/// file (absent here) or the manifest's own already-concrete requirement.
///
/// `allow: ["0BSD"]` is deliberately a real SPDX id that none of these four fixture
/// dependencies actually carry: an *unknown* license (tier-3 prefetch silently failing,
/// e.g. from a network error) never violates a policy ([`deps_core::licenses::evaluate`]'s
/// `evaluate_unknown_license_never_violates` contract) — so this can only produce a
/// `Category::License` finding when a real, non-"0BSD" license genuinely reached the
/// policy engine, which is exactly the property this test protects.
mod tier3_license_prefetch_parity {
    use super::*;
    use deps_cli::report::Category;

    #[cfg(feature = "dart")]
    #[tokio::test]
    #[ignore = "hits the real pub.dev API"]
    async fn test_live_dart_tier3_license_feeds_check_license_policy() {
        let (registry, ctx) =
            live_context(LicensePolicyConfig::new().with_allow(vec!["0BSD".to_string()]));
        let url = deps_core::test_util::test_uri("/test/pubspec.yaml");
        let manifest_path = url.to_file_path().expect("file-scheme uri");
        let ecosystem = registry.for_uri(&url).expect("Dart ecosystem not found");
        // A bare exact pin (no `^`), not `deps-lsp`'s live test's `^1.0.0` — Dart is
        // `BareRequirementPolicy::Concrete`, so this resolves without needing a lock file.
        let content = "dependencies:\n  http: 1.2.0\n";

        let result = check_manifest(&ecosystem, &manifest_path, &manifest_path, content, &ctx)
            .await
            .expect("check_manifest must not fail for a well-formed fixture");

        assert!(
            result
                .findings
                .iter()
                .any(|f| f.category == Category::License
                    && f.dependency_name.as_deref() == Some("http")),
            "expected a license-policy finding for 'http', got: {:?}",
            result.findings
        );
    }

    #[cfg(feature = "swift")]
    #[tokio::test]
    #[ignore = "hits the real GitHub API"]
    async fn test_live_swift_tier3_license_feeds_check_license_policy() {
        let (registry, ctx) =
            live_context(LicensePolicyConfig::new().with_allow(vec!["0BSD".to_string()]));
        let url = deps_core::test_util::test_uri("/test/Package.swift");
        let manifest_path = url.to_file_path().expect("file-scheme uri");
        let ecosystem = registry.for_uri(&url).expect("Swift ecosystem not found");
        // `.exact(...)` (parsed to an explicit `=`-pinned requirement), not `deps-lsp`'s
        // live test's `.upToNextMajor(from:)` range — resolves without needing a lock file.
        let content =
            r#".package(url: "https://github.com/apple/swift-nio.git", .exact("2.65.0"))"#;

        let result = check_manifest(&ecosystem, &manifest_path, &manifest_path, content, &ctx)
            .await
            .expect("check_manifest must not fail for a well-formed fixture");

        let has_license_finding = result.findings.iter().any(|f| {
            f.category == Category::License
                && f.dependency_name.as_deref() == Some("apple/swift-nio")
        });
        // #1283 S2: the license feed swallows *any* fetch error to "no license found", so a
        // missing finding alone can't tell an expected rate limit apart from a real bug —
        // see `should_skip_on_empty_result`'s doc.
        if deps_core::test_util::should_skip_on_empty_result(
            !has_license_finding,
            "test_live_swift_tier3_license_feeds_check_license_policy",
            ecosystem
                .registry()
                .get_versions(&deps_core::PackageName::new("apple/swift-nio")),
        )
        .await
        {
            return;
        }
        assert!(
            has_license_finding,
            "expected a license-policy finding for 'apple/swift-nio', got: {:?}",
            result.findings
        );
    }

    #[cfg(feature = "gradle")]
    #[tokio::test]
    #[ignore = "hits the real Maven Central API"]
    async fn test_live_gradle_tier3_license_feeds_check_license_policy() {
        // Held per `deps_core::fs_probe::snapshot_guard`'s doc: gradle's `parse_manifest`
        // transitively touches fs_probe, and other tests in this binary do too.
        let _guard = deps_core::fs_probe::snapshot_guard_async().await;
        let (registry, ctx) =
            live_context(LicensePolicyConfig::new().with_allow(vec!["0BSD".to_string()]));
        let url = deps_core::test_util::test_uri("/test/build.gradle.kts");
        let manifest_path = url.to_file_path().expect("file-scheme uri");
        let ecosystem = registry.for_uri(&url).expect("Gradle ecosystem not found");
        let content =
            "dependencies {\n    implementation(\"com.squareup.okhttp3:okhttp:4.12.0\")\n}\n";

        let result = check_manifest(&ecosystem, &manifest_path, &manifest_path, content, &ctx)
            .await
            .expect("check_manifest must not fail for a well-formed fixture");

        assert!(
            result
                .findings
                .iter()
                .any(|f| f.category == Category::License
                    && f.dependency_name.as_deref() == Some("com.squareup.okhttp3:okhttp")),
            "expected a license-policy finding for 'com.squareup.okhttp3:okhttp', got: {:?}",
            result.findings
        );
    }

    #[cfg(feature = "deno")]
    #[tokio::test]
    #[ignore = "hits the real JSR API"]
    async fn test_live_deno_tier3_license_feeds_check_license_policy() {
        let (registry, ctx) =
            live_context(LicensePolicyConfig::new().with_allow(vec!["0BSD".to_string()]));
        let url = deps_core::test_util::test_uri("/test/deno.json");
        let manifest_path = url.to_file_path().expect("file-scheme uri");
        let ecosystem = registry.for_uri(&url).expect("Deno ecosystem not found");
        // A bare *full* version (no `^`/`~` range operator), not `deps-lsp`'s live test's
        // `^1.0` — Deno is `BareRequirementPolicy::ConcreteIfFullVersion` (#667), so this
        // resolves to an in-use version straight from the manifest requirement, with no
        // lock file needed (unlike `deps-lsp`'s test, which injects `resolved_versions`
        // directly into `DocumentState`, a shortcut `check_manifest`'s public API has no
        // equivalent for).
        let content = r#"{"imports": {"@std/fs": "jsr:@std/fs@1.0.24"}}"#;

        let result = check_manifest(&ecosystem, &manifest_path, &manifest_path, content, &ctx)
            .await
            .expect("check_manifest must not fail for a well-formed fixture");

        assert!(
            result
                .findings
                .iter()
                .any(|f| f.category == Category::License
                    && f.dependency_name.as_deref() == Some("jsr:@std/fs")),
            "expected a license-policy finding for 'jsr:@std/fs', got: {:?}",
            result.findings
        );
    }
}

/// Issue #1133 critic S2: a CI-executable (never `#[ignore]`) regression guard that
/// `check_manifest` actually reaches `prefetch_tier3_licenses` — every test in
/// `tier3_license_prefetch_parity` above is `#[ignore]`d (live network), so deleting the
/// `licenses.extend`/`tokio::join!` wiring in `report.rs` would leave
/// `cargo nextest run --workspace --all-features` green. This module closes that gap with a
/// fully network-free `Ecosystem` test double
/// ([`deps_engine::test_util::TestTier3Ecosystem`]) instead: no `#[ignore]`, no real
/// registry, runs on every `cargo nextest run` invocation.
mod tier3_wiring_regression {
    use super::*;
    use deps_cli::report::Category;
    use deps_core::Ecosystem;
    use deps_core::policy_config::DiagnosticsConfig;
    use deps_engine::test_util::TestTier3Ecosystem;

    /// A minimal, non-offline [`CheckContext`] under `policy` — unlike [`live_context`], the
    /// `Ecosystem` this is paired with never touches the network regardless of the
    /// `offline` flag, so this stays deterministic and fast without needing one.
    fn wiring_test_context(policy: PolicyConfig) -> CheckContext {
        let cache = Arc::new(HttpCache::new());
        CheckContext {
            cache: Arc::clone(&cache),
            osv: Arc::new(OsvClient::new(Arc::clone(&cache))),
            deps_dev: Arc::new(deps_core::DepsDevClient::new(cache)),
            lockfile_cache: Arc::new(deps_core::lockfile::LockFileCache::new()),
            policy,
        }
    }

    #[tokio::test]
    async fn check_manifest_reaches_tier3_prefetch_wiring_without_network() {
        // OSV disabled so `ctx.osv.scan(..)` (a real network client) is never invoked —
        // this test's only network-shaped call is `TestTier3Ecosystem::fetch_license`,
        // which never touches a socket.
        let policy = PolicyConfig {
            diagnostics: DiagnosticsConfig::new().with_vulnerabilities_enabled(false),
            license_policy: LicensePolicyConfig::new().with_allow(vec!["0BSD".to_string()]),
            ..PolicyConfig::default()
        };
        let ctx = wiring_test_context(policy);

        let ecosystem: Arc<dyn Ecosystem> =
            Arc::new(TestTier3Ecosystem::returning(vec!["MIT".to_string()]));
        let url = deps_core::test_util::test_uri("/test/manifest.toml");
        let manifest_path = url.to_file_path().expect("file-scheme uri");

        let result = check_manifest(&ecosystem, &manifest_path, &manifest_path, "unused", &ctx)
            .await
            .expect("check_manifest must not fail for this fixture");

        assert!(
            result
                .findings
                .iter()
                .any(|f| f.category == Category::License
                    && f.dependency_name.as_deref() == Some("dep-0")),
            "check_manifest did not reach the tier-3 license prefetch wiring: {:?}",
            result.findings
        );
    }

    /// The M1 gate's other half: with no `license_policy` configured, `check_manifest` must
    /// never call `Ecosystem::fetch_license` at all — a [`TestTier3Ecosystem::pending`]
    /// would hang this test forever if the gate didn't short-circuit before it.
    #[tokio::test]
    async fn check_manifest_skips_tier3_prefetch_when_license_policy_is_empty() {
        let policy = PolicyConfig {
            diagnostics: DiagnosticsConfig::new().with_vulnerabilities_enabled(false),
            ..PolicyConfig::default()
        };
        assert!(
            policy.license_policy.to_policy().is_empty(),
            "test setup bug: this policy must have no license_policy configured"
        );
        let ctx = wiring_test_context(policy);

        let ecosystem: Arc<dyn Ecosystem> = Arc::new(TestTier3Ecosystem::pending());
        let url = deps_core::test_util::test_uri("/test/manifest.toml");
        let manifest_path = url.to_file_path().expect("file-scheme uri");

        tokio::time::timeout(
            Duration::from_secs(5),
            check_manifest(&ecosystem, &manifest_path, &manifest_path, "unused", &ctx),
        )
        .await
        .expect("must not hang: an empty license_policy must skip the tier-3 fetch entirely")
        .expect("check_manifest must not fail for this fixture");
    }

    /// Code review finding 6: `deps-cli update`'s default mode declares
    /// `AnalysisScope::update_default()`, which must skip the tier-3 license prefetch even when a
    /// non-empty `license_policy` is configured — proving this is a genuinely separate gate
    /// from the pre-existing empty-policy short-circuit the test above covers, not the same
    /// mechanism under a different name. A `TestTier3Ecosystem::pending()` would hang this
    /// test forever if `analyze_manifest` reached `Ecosystem::fetch_license` at all.
    #[tokio::test]
    async fn analyze_manifest_scope_none_skips_tier3_prefetch_even_with_a_configured_policy() {
        let policy = PolicyConfig {
            diagnostics: DiagnosticsConfig::new().with_vulnerabilities_enabled(false),
            license_policy: deps_core::policy_config::LicensePolicyConfig::new()
                .with_allow(vec!["0BSD".to_string()]),
            ..PolicyConfig::default()
        };
        assert!(
            !policy.license_policy.to_policy().is_empty(),
            "test setup bug: this policy must have a configured license_policy"
        );
        let ctx = wiring_test_context(policy);

        let ecosystem: Arc<dyn Ecosystem> = Arc::new(TestTier3Ecosystem::pending());
        let url = deps_core::test_util::test_uri("/test/manifest.toml");
        let manifest_path = url.to_file_path().expect("file-scheme uri");

        tokio::time::timeout(
            Duration::from_secs(5),
            deps_cli::analyze::analyze_manifest(
                &ecosystem,
                &manifest_path,
                "unused",
                &ctx,
                deps_cli::analyze::AnalysisScope::update_default(),
            ),
        )
        .await
        .expect(
            "must not hang: AnalysisScope::update_default() must skip the tier-3 fetch regardless of \
             license_policy",
        )
        .expect("analyze_manifest must not fail for this fixture");
    }
}

/// Issue #1521 item 2: `deps-engine`'s `gossip_cooldown_filter_tests` thoroughly cover spec
/// 074's version-selection filter against a hand-constructed `GossipFindings` map, but nothing
/// exercised the prefetch *call itself* at `deps-cli::analyze::analyze_manifest`'s call site —
/// its ecosystem/offline/opt-in gating and `EcosystemId` -> `DepsDevSystem` mapping. Mirrors
/// `tier3_wiring_regression`'s shape (a network-free `Ecosystem` double plus a real assertion
/// that the wiring was — or, for the negative cases, was not — reached), but against a
/// `mockito`-backed `DepsDevClient::for_test` instead of an offline `HttpCache`, since GOSSIP's
/// gate is opt-in (`[gossip].enabled`) rather than offline-default.
mod gossip_prefetch_wiring_regression {
    use super::*;
    use deps_core::Metadata;
    use deps_core::ecosystem::BoxFuture;
    use deps_core::ecosystem::private::Sealed;
    use deps_core::policy_config::{DiagnosticsConfig, GossipConfig};
    use deps_core::{
        Dependency, Ecosystem, EcosystemId, PackageName, ParseResult, Registry, VersionReq,
    };
    use deps_engine::test_util::{StubRegistry, TestTier3Ecosystem};
    use std::any::Any;

    struct StubDep {
        name: PackageName,
    }
    impl Dependency for StubDep {
        fn name(&self) -> &PackageName {
            &self.name
        }
        fn name_range(&self) -> deps_core::position::Range {
            deps_core::position::Range::default()
        }
        fn version_requirement(&self) -> Option<&VersionReq> {
            None
        }
        fn version_range(&self) -> Option<deps_core::position::Range> {
            None
        }
        fn source(&self) -> deps_core::parser::DependencySource {
            deps_core::parser::DependencySource::Registry
        }
        fn as_any(&self) -> &dyn Any {
            self
        }
    }

    struct StubParseResult {
        dep: StubDep,
        uri: url::Url,
    }
    impl ParseResult for StubParseResult {
        fn dependencies(&self) -> Vec<&dyn Dependency> {
            vec![&self.dep]
        }
        fn workspace_root(&self) -> Option<&std::path::Path> {
            None
        }
        fn uri(&self) -> &url::Url {
            &self.uri
        }
        fn as_any(&self) -> &dyn Any {
            self
        }
    }

    /// A GOSSIP-covered (`EcosystemId::Cargo`) `Ecosystem` double whose registry never
    /// touches the network (reuses [`StubRegistry`]). [`TestTier3Ecosystem`] can't stand in
    /// for the positive-wiring case below since it hardcodes `EcosystemId::Dart`, one of the
    /// seven ecosystems `deps_dev_system` deliberately does *not* cover.
    struct CoveredEcosystem;
    impl Sealed for CoveredEcosystem {}
    impl Ecosystem for CoveredEcosystem {
        fn ecosystem_id(&self) -> EcosystemId {
            EcosystemId::Cargo
        }
        fn display_name(&self) -> &'static str {
            "test-gossip-covered"
        }
        fn manifest_filenames(&self) -> &[&'static str] {
            &[]
        }
        fn parse_manifest<'a>(
            &'a self,
            _content: &'a str,
            uri: &'a url::Url,
        ) -> BoxFuture<'a, deps_core::Result<Box<dyn ParseResult>>> {
            let uri = uri.clone();
            Box::pin(async move {
                Ok(Box::new(StubParseResult {
                    dep: StubDep {
                        name: PackageName::new("dep-0"),
                    },
                    uri,
                }) as Box<dyn ParseResult>)
            })
        }
        fn registry(&self) -> Arc<dyn Registry> {
            Arc::new(StubRegistry)
        }
        fn formatter(&self) -> &dyn deps_core::lsp_helpers::EcosystemFormatter {
            &deps_core::test_util::StubFormatter::DEFAULT
        }
        fn completion_insert_text(&self, _metadata: &dyn Metadata) -> Option<String> {
            None
        }
        fn as_any(&self) -> &dyn Any {
            self
        }
    }

    /// A [`CheckContext`] whose `deps_dev` client is `mockito`-backed instead of pointed at
    /// the real deps.dev API, otherwise identical to `tier3_wiring_regression::wiring_test_context`.
    fn mock_gossip_context(policy: PolicyConfig, deps_dev_base_url: String) -> CheckContext {
        let cache = Arc::new(HttpCache::new());
        CheckContext {
            cache: Arc::clone(&cache),
            osv: Arc::new(OsvClient::new(Arc::clone(&cache))),
            deps_dev: Arc::new(deps_core::DepsDevClient::for_test(cache, deps_dev_base_url)),
            lockfile_cache: Arc::new(deps_core::lockfile::LockFileCache::new()),
            policy,
        }
    }

    fn covered_ecosystem_manifest_path() -> std::path::PathBuf {
        let url = deps_core::test_util::test_uri("/test/manifest.toml");
        url.to_file_path().expect("file-scheme uri")
    }

    fn no_osv_policy() -> PolicyConfig {
        PolicyConfig {
            diagnostics: DiagnosticsConfig::new().with_vulnerabilities_enabled(false),
            ..PolicyConfig::default()
        }
    }

    #[tokio::test]
    async fn analyze_manifest_reaches_gossip_prefetch_for_a_covered_ecosystem_when_enabled() {
        let mut server = mockito::Server::new_async().await;
        let mock = server
            .mock("POST", "/v3alpha/findingsbatch")
            // Critique M6(a): asserts the request body actually names `CoveredEcosystem`'s
            // `EcosystemId::Cargo` as deps.dev's uppercase `"CARGO"` system — without this,
            // the module doc's claim of covering the `EcosystemId` -> `DepsDevSystem` mapping
            // was unverified; a caller passing the wrong `system` would still make this test
            // pass since the earlier version only checked that *a* request arrived.
            .match_body(mockito::Matcher::PartialJson(serde_json::json!({
                "requests": [{"packageKey": {"system": "CARGO"}}]
            })))
            .with_status(200)
            .with_body(r#"{"responses":[],"nextPageToken":""}"#)
            .create_async()
            .await;

        let policy = PolicyConfig {
            gossip: GossipConfig::new().with_enabled(true),
            ..no_osv_policy()
        };
        let ctx = mock_gossip_context(policy, server.url());
        let ecosystem: Arc<dyn Ecosystem> = Arc::new(CoveredEcosystem);
        let manifest_path = covered_ecosystem_manifest_path();

        deps_cli::analyze::analyze_manifest(
            &ecosystem,
            &manifest_path,
            "unused",
            &ctx,
            deps_cli::analyze::AnalysisScope::update_default(),
        )
        .await
        .expect("analyze_manifest must not fail for this fixture");

        mock.assert_async().await;
    }

    /// Critique M6(a): the offline gate was previously untested — `[gossip].enabled = true`
    /// alone must not fire the prefetch when `network.offline` is also set.
    #[tokio::test]
    async fn analyze_manifest_skips_gossip_prefetch_when_offline() {
        let mut server = mockito::Server::new_async().await;
        let mock = server
            .mock("POST", "/v3alpha/findingsbatch")
            .with_status(200)
            .with_body(r#"{"responses":[],"nextPageToken":""}"#)
            .expect(0)
            .create_async()
            .await;

        let policy = PolicyConfig {
            gossip: GossipConfig::new().with_enabled(true),
            network: deps_core::policy_config::NetworkConfig::new().with_offline(true),
            ..no_osv_policy()
        };
        let ctx = mock_gossip_context(policy, server.url());
        // `HttpCache::new()` (used by `mock_gossip_context`) defaults to online — the offline
        // signal `fetch_gossip_findings_batch` actually reads comes from `ctx.policy.network`,
        // routed through `analyze_manifest`'s own offline plumbing, not the cache's own flag.
        let ecosystem: Arc<dyn Ecosystem> = Arc::new(CoveredEcosystem);
        let manifest_path = covered_ecosystem_manifest_path();

        deps_cli::analyze::analyze_manifest(
            &ecosystem,
            &manifest_path,
            "unused",
            &ctx,
            deps_cli::analyze::AnalysisScope::update_default(),
        )
        .await
        .expect("analyze_manifest must not fail for this fixture");

        mock.assert_async().await;
    }

    #[tokio::test]
    async fn analyze_manifest_skips_gossip_prefetch_for_an_uncovered_ecosystem() {
        let mut server = mockito::Server::new_async().await;
        let mock = server
            .mock("POST", "/v3alpha/findingsbatch")
            .with_status(200)
            .with_body(r#"{"responses":[],"nextPageToken":""}"#)
            .expect(0)
            .create_async()
            .await;

        let policy = PolicyConfig {
            gossip: GossipConfig::new().with_enabled(true),
            ..no_osv_policy()
        };
        let ctx = mock_gossip_context(policy, server.url());
        // `EcosystemId::Dart` — not one of the 7 `deps_dev_system`-covered systems.
        let ecosystem: Arc<dyn Ecosystem> = Arc::new(TestTier3Ecosystem::returning(Vec::new()));
        let manifest_path = covered_ecosystem_manifest_path();

        deps_cli::analyze::analyze_manifest(
            &ecosystem,
            &manifest_path,
            "unused",
            &ctx,
            deps_cli::analyze::AnalysisScope::update_default(),
        )
        .await
        .expect("analyze_manifest must not fail for this fixture");

        mock.assert_async().await;
    }

    #[tokio::test]
    async fn analyze_manifest_skips_gossip_prefetch_when_gossip_disabled() {
        let mut server = mockito::Server::new_async().await;
        let mock = server
            .mock("POST", "/v3alpha/findingsbatch")
            .with_status(200)
            .with_body(r#"{"responses":[],"nextPageToken":""}"#)
            .expect(0)
            .create_async()
            .await;

        // `[gossip].enabled` defaults to `false` — `no_osv_policy()` leaves it untouched.
        let ctx = mock_gossip_context(no_osv_policy(), server.url());
        let ecosystem: Arc<dyn Ecosystem> = Arc::new(CoveredEcosystem);
        let manifest_path = covered_ecosystem_manifest_path();

        deps_cli::analyze::analyze_manifest(
            &ecosystem,
            &manifest_path,
            "unused",
            &ctx,
            deps_cli::analyze::AnalysisScope::update_default(),
        )
        .await
        .expect("analyze_manifest must not fail for this fixture");

        mock.assert_async().await;
    }

    /// Issue #1521 item 4: `update --security-only`'s `AnalysisScope::vulnerabilities_only()`
    /// must skip the GOSSIP prefetch even with `[gossip].enabled = true` — that mode's fix
    /// target never reads a GOSSIP-filtered `latest` at all.
    #[tokio::test]
    async fn analyze_manifest_skips_gossip_prefetch_under_security_only_scope() {
        let mut server = mockito::Server::new_async().await;
        let mock = server
            .mock("POST", "/v3alpha/findingsbatch")
            .with_status(200)
            .with_body(r#"{"responses":[],"nextPageToken":""}"#)
            .expect(0)
            .create_async()
            .await;

        let policy = PolicyConfig {
            gossip: GossipConfig::new().with_enabled(true),
            ..no_osv_policy()
        };
        let ctx = mock_gossip_context(policy, server.url());
        let ecosystem: Arc<dyn Ecosystem> = Arc::new(CoveredEcosystem);
        let manifest_path = covered_ecosystem_manifest_path();

        deps_cli::analyze::analyze_manifest(
            &ecosystem,
            &manifest_path,
            "unused",
            &ctx,
            deps_cli::analyze::AnalysisScope::vulnerabilities_only(),
        )
        .await
        .expect("analyze_manifest must not fail for this fixture");

        mock.assert_async().await;
    }
}