alef 0.81.0

Opinionated polyglot binding generator for Rust libraries
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
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
//! Fail a generation run whose generated Rust manifest is vouched for beside a committed
//! `Cargo.lock` that can no longer resolve against it.
//!
//! ~keep alef: a consumer regenerated cleanly (`alef all --clean`, exit 0) and was then unable
//! to build the generated e2e crate at all: its committed `e2e/rust/Cargo.lock` pinned a
//! transitive registry dependency one minor behind what the crate's *path* dependency now
//! required, so `cargo metadata --locked` in that directory failed outright. Alef reported
//! nothing, because both mechanisms it had were keyed on the wrong fact:
//!
//! 1. [`super::version_lockfiles::relock_lockfiles_beside_changed_manifests`] relocks only when
//!    *alef's own manifest bytes changed in this run*. The requirement that moved lived in a
//!    hand-written path dependency alef neither generates nor watches, so the generated manifest
//!    was byte-identical and the hook never fired. No amount of fixing the relock hook closes
//!    this: it is watching a file that did not change.
//! 2. That relock is best-effort anyway (`cargo update --offline -w`, warn-only), so even when
//!    it does fire it can leave the lock stale and still exit 0.
//!
//! This module adds the missing observation rather than a third write path: after generation
//! completes, every directory holding a manifest this run generated is checked for a committed
//! lock that contradicts it, and a contradiction is recorded as a stage failure. Alef still
//! never authors a `Cargo.lock` — it only refuses to keep claiming a manifest is good when the
//! lock beside it says otherwise.

use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::path::{Path, PathBuf};

/// Upper bound on path-dependency manifests walked from one generated manifest. A malformed or
/// adversarial tree of `path = ` links cannot make this walk unbounded; the visited set already
/// makes cycles terminate, this caps sheer breadth.
const MAX_REACHABLE_MANIFESTS: usize = 512;

/// Dependency tables a manifest can declare, in the order they are read.
const DEPENDENCY_TABLES: [&str; 3] = ["dependencies", "build-dependencies", "dev-dependencies"];

/// One version requirement reachable from a generated manifest that no version present in the
/// sibling `Cargo.lock` satisfies.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleLockFinding {
    /// The committed lock that contradicts the requirement.
    pub(crate) lock: PathBuf,
    /// The manifest the requirement is written in — often a path dependency, not the generated
    /// manifest itself, which is exactly why "did alef rewrite this file" could not see it.
    pub(crate) declared_in: PathBuf,
    /// Package name as cargo resolves it (the `package = ` rename target when one is used).
    pub(crate) dependency: String,
    /// The requirement text as written.
    pub(crate) requirement: String,
    /// Every version of `dependency` the lock does pin, sorted, for the report.
    pub(crate) locked_versions: Vec<String>,
}

/// A single `name = req` pair read off some manifest in the reachable set.
struct DeclaredRequirement {
    manifest: PathBuf,
    name: String,
    requirement: String,
}

/// Check every directory in which this run generated a `Cargo.toml` for a committed
/// `Cargo.lock` that contradicts it, returning the failure to record when one does.
///
/// `generated_paths` is the run's own set of generated output paths, so the check covers exactly
/// the manifests alef vouches for and nothing else — a lock beside a manifest alef did not write
/// is none of its business.
/// Retained as the unmodified control the `_tolerating_pending_publish` variant is
/// differentially tested against: the pending-publish exemption is only meaningful if the
/// plain check still fails on the same input. No production call site remains. ~keep
#[cfg(test)]
pub(crate) fn check_generated_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
    // `workspace_root`/`canonical` are unused whenever `canonical` is `None`: the tolerating
    // variant returns before either is read, so this dummy root is never dereferenced. ~keep
    check_generated_lock_freshness_tolerating_pending_publish(generated_paths, Path::new("."), None)
}

/// The directories this run generated a `Cargo.toml` in, paired with every requirement in that
/// tree a committed `Cargo.lock` cannot satisfy — the shared collection step behind both
/// [`check_generated_lock_freshness`] and
/// [`check_generated_lock_freshness_tolerating_pending_publish`].
fn collect_generated_lock_findings(generated_paths: &HashSet<PathBuf>) -> Vec<StaleLockFinding> {
    let mut directories = BTreeSet::new();
    for path in generated_paths {
        if path.file_name().and_then(|name| name.to_str()) != Some("Cargo.toml") {
            continue;
        }
        if let Some(dir) = path.parent() {
            directories.insert(dir.to_path_buf());
        }
    }
    let mut findings = Vec::new();
    for dir in &directories {
        findings.extend(stale_lock_findings(dir));
    }
    tracing::debug!(
        manifest_dirs = directories.len(),
        findings = findings.len(),
        "checked generated Rust manifests against their committed lockfiles"
    );
    findings
}

/// Same check as [`check_generated_lock_freshness`], except a finding fully explained by this
/// crate's own pending, not-yet-published release version is downgraded to a `tracing::warn!`
/// instead of failing the stage.
///
/// ~keep `alef generate`/`alef all` run this immediately after `sync_versions`'s own
/// `relock_cargo_lockfiles` -- which already treats exactly this disagreement as unresolvable and
/// best-effort (a warn-only skip, see that function's doc) -- and immediately before `alef
/// validate versions`, the actual release gate, which tolerates it too via
/// `checks_pass`/`check_release_lock_freshness`. Only this generation-time check disagreed,
/// hard-failing on the one disagreement every other stage in the same pipeline already treats as
/// expected and temporary: a `test_apps`/e2e manifest requiring this crate's own version at the
/// exact number being released, which cannot resolve until the release is published. That made it
/// structurally impossible for a version-bumped-but-unpublished repo to run `alef all` clean, with
/// no distinction from a genuine third-party lock drift (the `tower-http` incident
/// [`super::version_lockfiles`] was built for), which must still fail. Reuses
/// [`super::version_lockfiles::explained_by_pending_publish`] and
/// [`crate::cli::commands::version_manifests::discover_cargo_locks`] -- the exact same read-only
/// classification the release gate already relies on -- rather than a second, independently
/// derived notion of "blocked", so the two can only ever agree on what counts as pending.
pub(crate) fn check_generated_lock_freshness_tolerating_pending_publish(
    generated_paths: &HashSet<PathBuf>,
    workspace_root: &Path,
    canonical: Option<&str>,
) -> Option<anyhow::Error> {
    let findings = collect_generated_lock_findings(generated_paths);
    if findings.is_empty() {
        return None;
    }
    let Some(canonical) = canonical else {
        return Some(anyhow::anyhow!(stale_lock_message(&findings)));
    };

    let tracked = crate::cli::git::tracked_paths_under(workspace_root);
    let blocked: std::collections::HashMap<PathBuf, String> =
        crate::cli::commands::version_manifests::discover_cargo_locks(workspace_root, canonical, tracked.as_ref())
            .into_iter()
            .filter_map(|lock| lock.blocked_on_publish.map(|waiting_on| (lock.path, waiting_on)))
            .collect();

    let (pending, real): (Vec<_>, Vec<_>) = findings
        .into_iter()
        .partition(|finding| super::version_lockfiles::explained_by_pending_publish(finding, &blocked));

    if !pending.is_empty() {
        tracing::warn!(
            "{} committed Cargo.lock pin(s) below require this crate's own version, which is not on the \
             registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
            pending.len(),
            stale_lock_message(&pending)
        );
    }
    if real.is_empty() {
        None
    } else {
        Some(anyhow::anyhow!(stale_lock_message(&real)))
    }
}

/// The exact `(name, requirement)` alef's own registry-mode `test_apps` generator would write
/// for `lang`'s self-dependency on this crate's own published package -- the uv/pnpm sibling of
/// the cargo path's `discover_cargo_locks`/`registry_dependencies_on_local_crates`.
///
/// Reuses [`crate::core::config::e2e::E2eConfig::resolve_package`] -- the exact function
/// [`crate::e2e::codegen::python::PythonE2eCodegen`] and
/// [`crate::e2e::codegen::typescript::TypeScriptCodegen`] call to resolve their own `pkg_name`/
/// `pkg_version` in `DependencyMode::Registry` -- rather than re-deriving the package identity
/// from `[python]`/`[node]` config (`python_pip_name`/`node_package_name`), which is a DIFFERENT
/// knob: `packages/python/pyproject.toml`'s own `[project] name` and
/// `test_apps/python/pyproject.toml`'s dependency name are independently configurable and are not
/// guaranteed to agree (the e2e/test_apps package can be renamed, repathed, or version-pinned
/// separately from the published package's own manifest). `normalize` mirrors each generator's
/// own requirement-text rendering (`python`: `normalize_python_version`, PEP 508 comparator
/// handling; `node`: passed through verbatim, matching `render_package_json`'s "never strip an
/// explicit range operator" comment) so the returned `requirement` is byte-identical to what the
/// generator itself would have written, not a semver-equivalent reconstruction.
///
/// Deliberately conservative, returning `None` (never an exemption) unless BOTH `name` and
/// `version` are explicitly set on `[crates.e2e.registry.packages.<lang>]` (falling back to the
/// base `[crates.e2e.packages.<lang>]` only as `resolve_package` itself already does): the real
/// generators have a further fallback of their own for an unset name (derived from the e2e call's
/// `module`) and version (`resolved_version()`, then `"0.1.0"`), but registry-mode test apps are
/// only ever meaningful with an explicit, publishable package identity in practice, and guessing
/// at that derived fallback here risks matching a name this check has no real authority over.
fn registry_self_dependency(
    resolved_cfg: &crate::core::config::ResolvedCrateConfig,
    lang: &str,
    normalize: impl Fn(&str) -> String,
) -> Option<RegistrySelfDependency> {
    let mut e2e_config = resolved_cfg.e2e.clone()?;
    e2e_config.dep_mode = crate::core::config::e2e::DependencyMode::Registry;
    let package = e2e_config.resolve_package(lang)?;
    let name = package.name?;
    let version = package.version?;
    Some(RegistrySelfDependency {
        name,
        requirement: normalize(&version),
    })
}

/// See [`registry_self_dependency`].
struct RegistrySelfDependency {
    name: String,
    requirement: String,
}

/// Every requirement reachable from `manifest_dir/Cargo.toml` that the sibling
/// `manifest_dir/Cargo.lock` cannot satisfy.
///
/// Returns empty when either file is missing or unparseable: alef never authors a lockfile, so a
/// directory without one is a deliberate consumer choice, not a defect to report.
pub(crate) fn stale_lock_findings(manifest_dir: &Path) -> Vec<StaleLockFinding> {
    let manifest_path = manifest_dir.join("Cargo.toml");
    let lock_path = manifest_dir.join("Cargo.lock");
    if !manifest_path.is_file() {
        return Vec::new();
    }
    let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
        return Vec::new();
    };
    let locked = locked_versions(&lock_text);
    if locked.is_empty() {
        return Vec::new();
    }
    let mut findings = Vec::new();
    for declared in reachable_requirements(&manifest_path) {
        // ~keep The rule is deliberately one-sided: a requirement is reported only when its
        // package name IS pinned in the lock and NO pinned version satisfies it. A name absent
        // from the lock is never reported, because absence has many innocent explanations this
        // check is not equipped to tell apart from a real gap — cargo omits a path dependency's
        // dev-dependencies, a `[patch]`/`[replace]` entry can rewrite the resolved name, and a
        // renamed or platform-gated dependency can resolve to a name this reader did not derive.
        // Reporting absence would turn a healthy tree red; reporting a contradiction cannot,
        // because cargo itself refuses that lock. This check is therefore incomplete on purpose
        // and must stay that way: it is a guard against a false green, not a resolver.
        let Some(versions) = locked.get(&declared.name) else {
            continue;
        };
        let Ok(requirement) = semver::VersionReq::parse(&declared.requirement) else {
            continue;
        };
        if versions.iter().any(|version| requirement.matches(version)) {
            continue;
        }
        findings.push(StaleLockFinding {
            lock: lock_path.clone(),
            declared_in: declared.manifest.clone(),
            dependency: declared.name.clone(),
            requirement: declared.requirement.clone(),
            locked_versions: versions.iter().map(ToString::to_string).collect(),
        });
    }
    findings.sort_by(|left, right| {
        left.dependency
            .cmp(&right.dependency)
            .then_with(|| left.requirement.cmp(&right.requirement))
    });
    findings.dedup_by(|left, right| left.dependency == right.dependency && left.requirement == right.requirement);
    findings
}

/// `name -> every version pinned for it` from a `Cargo.lock`'s `[[package]]` array.
fn locked_versions(lock_text: &str) -> BTreeMap<String, Vec<semver::Version>> {
    let mut locked: BTreeMap<String, Vec<semver::Version>> = BTreeMap::new();
    let Some(packages) = toml::from_str::<toml::Value>(lock_text)
        .ok()
        .and_then(|value| value.get("package").and_then(toml::Value::as_array).cloned())
    else {
        return locked;
    };
    for package in packages {
        let (Some(name), Some(version)) = (
            package.get("name").and_then(toml::Value::as_str),
            package.get("version").and_then(toml::Value::as_str),
        ) else {
            continue;
        };
        if let Ok(parsed) = semver::Version::parse(version) {
            locked.entry(name.to_string()).or_default().push(parsed);
        }
    }
    for versions in locked.values_mut() {
        versions.sort();
    }
    locked
}

/// A manifest queued for the [`reachable_requirements`] walk, paired with the feature activation
/// state the edge that reached it (a `path = ` dependency table, or the walk's own root) requests
/// — the input [`activated_optional_dependencies`] needs to resolve which of *this* manifest's
/// own optional dependencies are actually reachable.
struct QueuedManifest {
    path: PathBuf,
    requested_features: Vec<String>,
    default_features: bool,
}

/// Walk `root_manifest` and, transitively, every manifest it reaches through a `path = `
/// dependency, collecting the version requirements each one declares.
///
/// The walk crosses path dependencies because that is where the observed breakage lived: the
/// generated crate is its own workspace root and depends on the crate under test by path, so
/// every registry requirement that actually constrains its lock is written one manifest away.
fn reachable_requirements(root_manifest: &Path) -> Vec<DeclaredRequirement> {
    let mut requirements = Vec::new();
    // ~keep The root itself is never gated by an external edge -- it is the crate being built
    // directly, so its own default features are active exactly as `cargo metadata` (no
    // `--no-default-features`) would resolve them, and it requests nothing beyond that.
    let mut queue = vec![QueuedManifest {
        path: root_manifest.to_path_buf(),
        requested_features: Vec::new(),
        default_features: true,
    }];
    let mut visited: HashSet<PathBuf> = HashSet::new();
    while let Some(item) = queue.pop() {
        if visited.len() >= MAX_REACHABLE_MANIFESTS {
            tracing::warn!(
                root = %root_manifest.display(),
                limit = MAX_REACHABLE_MANIFESTS,
                "stopped walking path dependencies at the manifest limit; lock freshness for this \
                 crate was checked against a partial requirement set"
            );
            break;
        }
        let key = std::fs::canonicalize(&item.path).unwrap_or_else(|_| item.path.clone());
        if !visited.insert(key) {
            continue;
        }
        let Ok(text) = std::fs::read_to_string(&item.path) else {
            continue;
        };
        let Ok(document) = toml::from_str::<toml::Value>(&text) else {
            continue;
        };
        // ~keep Only the crate alef generated contributes its dev-dependencies. Cargo does not
        // resolve a non-workspace path dependency's dev-dependencies at all, so reading them
        // would invent requirements the lock is never expected to satisfy.
        let is_root = item.path == root_manifest;
        let activated_optional_deps =
            activated_optional_dependencies(&document, &item.requested_features, item.default_features);
        collect_requirements(
            &item.path,
            &document,
            is_root,
            &activated_optional_deps,
            &mut requirements,
            &mut queue,
        );
    }
    requirements
}

/// The set of dependency keys (`[dependencies]` table aliases, not the `package = ` renamed
/// name) an optional dependency is activated under, given the features `requested` on this edge
/// plus `document`'s own default feature set when `default_features` is enabled.
///
/// ~keep Deliberately approximate, not a `cargo` feature resolver: it does not model
/// target-conditional feature edges, and a feature-array entry it cannot classify as a `dep:`
/// activation, a `dep/feature` (or weak `dep?/feature`) activation, or a plain named feature is
/// folded into BOTH interpretations (treated as an activated dependency key AND queued as a
/// feature to expand further) rather than dropped. Getting this wrong in the direction of
/// under-activating would silently resurrect the exact false positive
/// [`collect_one_requirement`]'s optional-dependency guard exists to remove -- a real
/// registry-sourced requirement dropping out of the check entirely -- which is a worse failure
/// mode here than over-activating a name that turns out not to be a dependency at all (harmless:
/// nothing looks it up).
fn activated_optional_dependencies(
    document: &toml::Value,
    requested: &[String],
    default_features: bool,
) -> HashSet<String> {
    let features_table = document.get("features").and_then(toml::Value::as_table);
    let mut activated_deps = HashSet::new();
    let mut queue: Vec<String> = requested.to_vec();
    if default_features {
        queue.push("default".to_string());
    }
    let mut visited_features: HashSet<String> = HashSet::new();
    while let Some(feature) = queue.pop() {
        if !visited_features.insert(feature.clone()) {
            continue;
        }
        let Some(entries) = features_table
            .and_then(|table| table.get(feature.as_str()))
            .and_then(toml::Value::as_array)
        else {
            continue;
        };
        for entry in entries {
            let Some(entry) = entry.as_str() else { continue };
            if let Some(dep_key) = entry.strip_prefix("dep:") {
                activated_deps.insert(dep_key.to_string());
            } else if let Some((dep_key, _sub_feature)) = entry.split_once('/') {
                activated_deps.insert(dep_key.trim_end_matches('?').to_string());
            } else {
                activated_deps.insert(entry.to_string());
                queue.push(entry.to_string());
            }
        }
    }
    activated_deps
}

/// The `features = [...]` / `default-features` an edge (a `path = ` dependency table, possibly
/// combined with its `{ workspace = true }` inherited entry) requests on the manifest it points
/// to.
///
/// ~keep `features` unions the inherited and local arrays -- and `default-features` prefers the
/// local table -- because Cargo lets a member augment (never replace) a workspace-inherited
/// dependency's `features` while overriding its `default-features` at the usage site.
fn edge_feature_request(table: &toml::Table, inherited_table: Option<&toml::Table>) -> (Vec<String>, bool) {
    let mut features: Vec<String> = inherited_table
        .and_then(|entry| entry.get("features"))
        .and_then(toml::Value::as_array)
        .into_iter()
        .flatten()
        .chain(
            table
                .get("features")
                .and_then(toml::Value::as_array)
                .into_iter()
                .flatten(),
        )
        .filter_map(|value| value.as_str().map(str::to_string))
        .collect();
    features.sort();
    features.dedup();
    let default_features = table
        .get("default-features")
        .or_else(|| inherited_table.and_then(|entry| entry.get("default-features")))
        .and_then(toml::Value::as_bool)
        .unwrap_or(true);
    (features, default_features)
}

/// Read one manifest's dependency tables — top level and every `[target.<cfg>.*]` variant —
/// pushing requirements onto `requirements` and path-dependency manifests onto `queue`.
fn collect_requirements(
    manifest_path: &Path,
    document: &toml::Value,
    include_dev: bool,
    activated_optional_deps: &HashSet<String>,
    requirements: &mut Vec<DeclaredRequirement>,
    queue: &mut Vec<QueuedManifest>,
) {
    let mut tables: Vec<&toml::Value> = vec![document];
    if let Some(targets) = document.get("target").and_then(toml::Value::as_table) {
        tables.extend(targets.values());
    }
    for table in tables {
        for section in DEPENDENCY_TABLES {
            if section == "dev-dependencies" && !include_dev {
                continue;
            }
            let Some(entries) = table.get(section).and_then(toml::Value::as_table) else {
                continue;
            };
            for (alias, spec) in entries {
                collect_one_requirement(manifest_path, alias, spec, activated_optional_deps, requirements, queue);
            }
        }
    }
}

/// Resolve `alias`'s `{ workspace = true }` inherited entry, when it has one, and the name cargo
/// actually resolves the dependency to (the `package = ` rename target when one is used).
///
/// ~keep An inherited entry can be either spelling `[workspace.dependencies]` accepts — the bare
/// string `dep = "1.26"` as often as the table form — so the string case has to be handled here
/// and not only in the table branch below. Reading only the table form is silent: the member
/// declares `{ workspace = true }`, no `version` is found beside it, and the requirement drops
/// out of the check entirely instead of erroring.
fn resolve_dependency_identity(
    manifest_path: &Path,
    alias: &str,
    table: &toml::Table,
) -> (Option<toml::Value>, String) {
    let inherited = table
        .get("workspace")
        .and_then(toml::Value::as_bool)
        .unwrap_or(false)
        .then(|| workspace_dependency_spec(manifest_path, alias))
        .flatten();
    let name = inherited
        .as_ref()
        .and_then(toml::Value::as_table)
        .and_then(|entry| entry.get("package"))
        .or_else(|| table.get("package"))
        .and_then(toml::Value::as_str)
        .unwrap_or(alias)
        .to_string();
    (inherited, name)
}

/// Resolve a single `alias = <spec>` entry into at most one requirement plus at most one further
/// manifest to walk.
fn collect_one_requirement(
    manifest_path: &Path,
    alias: &str,
    spec: &toml::Value,
    activated_optional_deps: &HashSet<String>,
    requirements: &mut Vec<DeclaredRequirement>,
    queue: &mut Vec<QueuedManifest>,
) {
    if let Some(requirement) = spec.as_str() {
        // A bare string entry (`dep = "1.26"`) has no `optional` key to set -- only the table
        // form can declare a dependency optional -- so this is always required.
        requirements.push(DeclaredRequirement {
            manifest: manifest_path.to_path_buf(),
            name: alias.to_string(),
            requirement: requirement.to_string(),
        });
        return;
    }
    let Some(table) = spec.as_table() else {
        return;
    };
    let (inherited, name) = resolve_dependency_identity(manifest_path, alias, table);
    let inherited_table = inherited.as_ref().and_then(toml::Value::as_table);
    // ~keep alef #A4 (tower-http incident): `optional` is read only off the LOCAL table, never
    // the inherited `[workspace.dependencies]` entry -- Cargo requires it be declared per member,
    // since a workspace-level default would make every member's activation identical. Activation
    // is checked against `alias` (the `[dependencies]` table key), not `name` (the resolved,
    // possibly `package = `-renamed identity): Cargo's `dep:`/`dep/feature` feature syntax always
    // refers to the dependency key, never the renamed package.
    let is_optional = table.get("optional").and_then(toml::Value::as_bool).unwrap_or(false);
    if is_optional && !activated_optional_deps.contains(alias) {
        return;
    }
    let (requested_features, default_features) = edge_feature_request(table, inherited_table);
    if let Some(relative) = table.get("path").and_then(toml::Value::as_str)
        && let Some(dir) = manifest_path.parent()
    {
        queue.push(QueuedManifest {
            path: normalize_lexically(&dir.join(relative).join("Cargo.toml")),
            requested_features,
            default_features,
        });
    }
    // ~keep A path or git dependency's pinned entry is not a registry version requirement: a
    // path package's locked version is read straight out of the manifest tree already walked
    // above, and a git dependency is locked by revision, not by the `version` field beside it.
    // Checking either adds no coverage for the defect this module exists for and both invent
    // false positives.
    let is_source_pinned = |entry: &toml::Table| entry.contains_key("path") || entry.contains_key("git");
    if is_source_pinned(table) || inherited_table.is_some_and(is_source_pinned) {
        return;
    }
    let requirement = match inherited.as_ref() {
        Some(value) => value
            .as_str()
            .or_else(|| value.get("version").and_then(toml::Value::as_str)),
        None => table.get("version").and_then(toml::Value::as_str),
    };
    let Some(requirement) = requirement else {
        return;
    };
    requirements.push(DeclaredRequirement {
        manifest: manifest_path.to_path_buf(),
        name,
        requirement: requirement.to_string(),
    });
}

/// Collapse `.` and `..` components without touching the filesystem.
///
/// ~keep Lexical, not `canonicalize`: the walked path may not exist yet (a misconfigured `path =
/// `), and a symlink-resolved path is the wrong thing to print at an operator who has to open
/// the file. `..` is only popped when a real named component precedes it, so a path that escapes
/// its own root keeps the leading `..` rather than silently becoming a different path.
fn normalize_lexically(path: &Path) -> PathBuf {
    let mut components: Vec<std::path::Component<'_>> = Vec::new();
    for component in path.components() {
        match component {
            std::path::Component::CurDir => {}
            std::path::Component::ParentDir if matches!(components.last(), Some(std::path::Component::Normal(_))) => {
                components.pop();
            }
            other => components.push(other),
        }
    }
    components.into_iter().collect()
}

/// The `[workspace.dependencies] <alias>` entry a `{ workspace = true }` dependency inherits.
///
/// Searches upward from `manifest_path` for the nearest ancestor manifest carrying a
/// `[workspace]` table and reads the alias out of it. Returns `None` when no such ancestor
/// exists or the alias is absent, which leaves the dependency unchecked — the one-sided rule in
/// [`stale_lock_findings`] applies here too: an unresolved inheritance must never be reported.
fn workspace_dependency_spec(manifest_path: &Path, alias: &str) -> Option<toml::Value> {
    // ~keep Starts at the manifest's own directory, not its parent: a root crate that is also
    // the workspace root declares `[workspace.dependencies]` in the very file whose
    // `{ workspace = true }` entry is being resolved, which is the most common shape of all.
    let mut directory = manifest_path.parent();
    while let Some(current) = directory {
        let candidate = current.join("Cargo.toml");
        if let Ok(text) = std::fs::read_to_string(&candidate)
            && let Ok(document) = toml::from_str::<toml::Value>(&text)
            && let Some(workspace) = document.get("workspace")
        {
            return workspace
                .get("dependencies")
                .and_then(toml::Value::as_table)
                .and_then(|table| table.get(alias))
                .cloned();
        }
        directory = current.parent();
    }
    None
}

/// Render the operator-facing failure: what disagrees, where each side said it, and the command
/// that reconciles them.
///
/// Reported, never rewritten: generation itself succeeded and alef does not author lockfiles,
/// so the fix is a command the operator runs in their own tree. ~keep
fn stale_lock_message(findings: &[StaleLockFinding]) -> String {
    let mut message = format!(
        "{} committed Cargo.lock pin(s) cannot satisfy a requirement from a manifest alef generated; \
         `cargo metadata --locked` and `cargo build --locked` will fail in these directories:",
        findings.len()
    );
    for finding in findings {
        message.push_str(&format!(
            "\n  - {}: `{}` is required as `{}` by {}, but the lock pins only {}. Fix with: cargo \
             update --manifest-path {} -p {}",
            finding.lock.display(),
            finding.dependency,
            finding.requirement,
            finding.declared_in.display(),
            finding.locked_versions.join(", "),
            finding
                .lock
                .parent()
                .unwrap_or(Path::new("."))
                .join("Cargo.toml")
                .display(),
            finding.dependency,
        ));
    }
    message.push_str(
        "\nA pin held back on purpose belongs in the manifest that declares the requirement -- a lockfile \
         cannot record an exception to its own resolution.",
    );
    message
}

/// Dependency buckets alef itself writes into a generated `package.json` -- see
/// `crate::e2e::codegen::typescript::config::render_package_json` (and its wasm counterpart),
/// which only ever populate `dependencies` / `devDependencies`. Checking a bucket alef never
/// writes would find nothing but a hand-authored drift this module has no business reporting.
const NODE_DEPENDENCY_BUCKETS: [&str; 2] = ["dependencies", "devDependencies"];

/// One `package.json` specifier whose sibling `pnpm-lock.yaml` records a different specifier for
/// the same dependency name and bucket.
///
/// Unlike [`StaleLockFinding`], there is no path-dependency walk: the requirement text and the
/// generated manifest are the same file, so the comparison is direct.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleNodeLockFinding {
    /// The committed `pnpm-lock.yaml` that contradicts the requirement.
    pub(crate) lock: PathBuf,
    /// The `package.json` alef generated the requirement in.
    pub(crate) declared_in: PathBuf,
    /// Which dependency table the requirement was declared in.
    pub(crate) bucket: &'static str,
    /// Package name as `package.json` spells it.
    pub(crate) dependency: String,
    /// The specifier text as written in `package.json`.
    pub(crate) requirement: String,
    /// The specifier text the lock records for the same name and bucket.
    pub(crate) locked_requirement: String,
}

/// Check every directory in which this run generated a `package.json` for a committed
/// `pnpm-lock.yaml` whose recorded specifiers disagree with it, returning the failure to record
/// when one does.
///
/// Mirrors [`check_generated_lock_freshness`] one call away in the same module rather than
/// sharing machinery with it: the cargo check's job is a transitive path-dependency walk followed
/// by semver resolution against a lock it must reconstruct, while this check's job is a direct
/// text comparison against a manifest alef already has in hand. The two are different problems
/// wearing similar names, and forcing them through one abstraction is how a later change to
/// either drifts the other -- see the `avoid-duplication` rule and the `two-generators-disagree`
/// pattern this repo is watching for. ~keep
///
/// `generated_paths` alone is structurally blind to a whole class of `package.json`: any emitted
/// `generated_header: false` (`crates/*-wasm/package.json`, `crates/*-node/package.json` and its
/// per-platform `npm/<platform>/package.json` siblings, ...) never carries an `alef:hash:` marker
/// -- JSON has no comment syntax to hold one -- so [`GeneratedFile::carries_alef_marker`] is
/// always `false` for it and [`super::generate::stampable_output_paths`] filters it out of
/// `current_gen_paths` before this function ever sees it. That is not "this run happened not to
/// touch it": it is every run, forever, for every manifest of that shape, from the day it is
/// first scaffolded. [`registered_unmarkable_manifest_dirs`] closes the gap by also consulting the
/// committed ownership record, which already tracks exactly these paths for an unrelated reason
/// (the write-time ownership guard). See that function's doc for why this is a general
/// registration rather than a wasm-specific carve-out. ~keep
/// Retained as the unmodified control the `_tolerating_pending_publish` variant is
/// differentially tested against: the pending-publish exemption is only meaningful if the
/// plain check still fails on the same input. No production call site remains. ~keep
#[cfg(test)]
pub(crate) fn check_generated_node_lock_freshness(
    generated_paths: &HashSet<PathBuf>,
    base_dir: &Path,
) -> Option<anyhow::Error> {
    check_generated_node_lock_freshness_tolerating_pending_publish(generated_paths, base_dir, None)
}

/// The shared collection step behind [`check_generated_node_lock_freshness`] and
/// [`check_generated_node_lock_freshness_tolerating_pending_publish`] -- see the former's doc
/// comment for the `registered_unmarkable_manifest_dirs` rationale.
fn collect_generated_node_lock_findings(
    generated_paths: &HashSet<PathBuf>,
    base_dir: &Path,
) -> Vec<StaleNodeLockFinding> {
    let mut directories = BTreeSet::new();
    for path in generated_paths {
        if path.file_name().and_then(|name| name.to_str()) != Some("package.json") {
            continue;
        }
        if let Some(dir) = path.parent() {
            directories.insert(dir.to_path_buf());
        }
    }
    let registered_dirs = registered_unmarkable_manifest_dirs(base_dir, "package.json");
    let registered_only = registered_dirs.difference(&directories).count();
    directories.extend(registered_dirs);
    let mut findings = Vec::new();
    for dir in &directories {
        findings.extend(stale_node_lock_findings(dir));
    }
    tracing::debug!(
        manifest_dirs = directories.len(),
        registered_only_dirs = registered_only,
        findings = findings.len(),
        "checked generated package.json files against their committed pnpm-lock.yaml"
    );
    findings
}

/// Same check as [`check_generated_node_lock_freshness`], except every finding in a lockfile
/// whose own self-dependency row is fully explained by this crate's pending, not-yet-published
/// registry-mode `test_apps` self-dependency is downgraded to a `tracing::warn!` instead of
/// failing the stage -- the npm sibling of
/// [`check_generated_lock_freshness_tolerating_pending_publish`]. See
/// [`registry_self_dependency`]'s doc for what "explained" means here and why it is deliberately
/// conservative.
///
/// ~keep alef #A5: tolerance is scoped to the whole LOCK, not just the self-dependency row that
/// explains it. A pending self-dependency (`@xberg-io/tree-sitter-language-pack@1.16.1`, not yet
/// published) makes the *entire* `pnpm-lock.yaml` unresolvable -- pnpm cannot even attempt to
/// relock the file to pick up an unrelated sibling drift (`@types/node`, `vitest`, `rollup`)
/// until that self-dependency publishes, so `pnpm install --lockfile-only` fails immediately with
/// `ERR_PNPM_NO_MATCHING_VERSION` on the self-dependency before it ever reaches the siblings. A
/// per-finding partition that hard-fails those siblings therefore prescribes a remedy the
/// operator cannot run yet. Once the release actually publishes, `collect_generated_node_lock_findings`
/// stops reporting the self-dependency row at all (the specifiers agree), so this exemption
/// narrows back down to nothing on its own -- it never permanently hides a lock's other findings.
pub(crate) fn check_generated_node_lock_freshness_tolerating_pending_publish(
    generated_paths: &HashSet<PathBuf>,
    base_dir: &Path,
    resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
    let findings = collect_generated_node_lock_findings(generated_paths, base_dir);
    if findings.is_empty() {
        return None;
    }
    let Some(self_dependency) = resolved_cfg.and_then(|cfg| registry_self_dependency(cfg, "node", str::to_string))
    else {
        return Some(anyhow::anyhow!(stale_node_lock_message(&findings)));
    };

    let pending_locks: HashSet<PathBuf> = findings
        .iter()
        .filter(|finding| {
            finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
        })
        .map(|finding| finding.lock.clone())
        .collect();
    let (pending, real): (Vec<_>, Vec<_>) = findings
        .into_iter()
        .partition(|finding| pending_locks.contains(&finding.lock));

    if !pending.is_empty() {
        tracing::warn!(
            "{} committed pnpm-lock.yaml pin(s) below require this crate's own version, which is not on the \
             registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
            pending.len(),
            stale_node_lock_message(&pending)
        );
    }
    if real.is_empty() {
        None
    } else {
        Some(anyhow::anyhow!(stale_node_lock_message(&real)))
    }
}

/// Directories holding a `file_name` manifest that [`GeneratedFile::carries_alef_marker`] can
/// never certify, because the format has no comment syntax to carry an `alef:hash:` marker at all
/// (`generated_header: false` JSON, principally `package.json`). A lock-freshness gate keyed only
/// on this run's in-memory `current_gen_paths` -- itself filtered by that same marker predicate,
/// see [`super::generate::stampable_output_paths`] -- structurally never examines these paths, in
/// any run, which is the gap this function exists to close.
///
/// Reads the committed ownership record ([`crate::cli::cache::read_committed_owned_paths`],
/// `.alef-ownership.toml`) instead: it is the durable, general-purpose list of every path alef has
/// authorised itself to own *precisely because* it cannot carry a marker -- populated by
/// `write_scaffold_files_report`'s own write guard the first time it creates such a file, for
/// every unmarkable manifest kind alef emits, not only `package.json` for wasm. Filtering that
/// list by `file_name` extends `generated_paths` with every alef-managed manifest of that name
/// the registry already knows about, including one this particular run did not touch (a
/// `--crate`-scoped run, or a language skipped by the per-language cache) -- which is strictly
/// more correct for a freshness check than "only what this run happened to regenerate": the drift
/// this gate exists to catch does not require this run to have written the manifest, only for the
/// manifest and its sibling lock to disagree right now.
///
/// General by construction: nothing here names `wasm` or `node`. `crates/*-node/package.json` is
/// `generated_header: false` for the identical reason `crates/*-wasm/package.json` is and was
/// found to share this exact blind spot while auditing it, and both are closed by the same call
/// with no per-backend special case. A future unmarkable manifest this registry starts tracking
/// -- a PHP `composer.json`-vs-`composer.lock` gate, should one ever be added -- would read from
/// this identical list rather than inventing its own. ~keep
fn registered_unmarkable_manifest_dirs(base_dir: &Path, file_name: &str) -> BTreeSet<PathBuf> {
    crate::cli::cache::read_committed_owned_paths(base_dir)
        .iter()
        .map(|relative| base_dir.join(relative))
        .filter(|path| path.file_name().and_then(|name| name.to_str()) == Some(file_name))
        .filter_map(|path| path.parent().map(Path::to_path_buf))
        .collect()
}

/// Every `dependencies` / `devDependencies` specifier declared in `package_json_dir/package.json`
/// that the sibling `package_json_dir/pnpm-lock.yaml` records a different specifier for.
///
/// Returns empty when either file is missing or unparseable: alef never authors a lockfile, so a
/// directory without one is a deliberate consumer choice, not a defect to report.
pub(crate) fn stale_node_lock_findings(package_json_dir: &Path) -> Vec<StaleNodeLockFinding> {
    let manifest_path = package_json_dir.join("package.json");
    let lock_path = package_json_dir.join("pnpm-lock.yaml");
    if !manifest_path.is_file() {
        return Vec::new();
    }
    let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
        return Vec::new();
    };
    let Ok(manifest_json) = serde_json::from_str::<serde_json::Value>(&manifest_text) else {
        return Vec::new();
    };
    let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
        return Vec::new();
    };
    let Ok(lock_yaml) = serde_saphyr::from_str::<serde_json::Value>(&lock_text) else {
        return Vec::new();
    };

    let mut findings = Vec::new();
    for bucket in NODE_DEPENDENCY_BUCKETS {
        let locked = locked_node_specifiers(&lock_yaml, bucket);
        if locked.is_empty() {
            continue;
        }
        let Some(declared) = manifest_json.get(bucket).and_then(serde_json::Value::as_object) else {
            continue;
        };
        for (name, spec_value) in declared {
            let Some(requirement) = spec_value.as_str() else {
                continue;
            };
            if !is_checkable_node_specifier(requirement) {
                continue;
            }
            // ~keep One-sided, matching `stale_lock_findings`'s rule above: a name absent from
            // the lock's bucket is never reported. Absence here is not only the ordinary
            // ambiguity (a dependency just added and not yet installed is indistinguishable from
            // one this reader failed to find) but also a hedge against misreading the lockfile's
            // own shape -- `locked_node_specifiers` falls back between the `importers.".".*`
            // (lockfileVersion 9+) and flat (lockfileVersion 6 and earlier) layouts, and a
            // lockfile in some third shape neither fallback anticipated looks identical to "name
            // absent". Reporting absence would turn an unfamiliar lockfile shape into a false
            // failure; only a contradiction pnpm's own frozen-lockfile check would reject is a
            // finding.
            let Some(locked_requirement) = locked.get(name.as_str()) else {
                continue;
            };
            if !is_checkable_node_specifier(locked_requirement) {
                continue;
            }
            if locked_requirement.trim() == requirement.trim() {
                continue;
            }
            findings.push(StaleNodeLockFinding {
                lock: lock_path.clone(),
                declared_in: manifest_path.clone(),
                bucket,
                dependency: name.clone(),
                requirement: requirement.to_string(),
                locked_requirement: locked_requirement.clone(),
            });
        }
    }
    findings.sort_by(|left, right| {
        left.bucket
            .cmp(right.bucket)
            .then_with(|| left.dependency.cmp(&right.dependency))
    });
    findings
}

/// `name -> specifier text` pnpm recorded for one dependency bucket of the lock's own project.
///
/// Tries the workspace-aware `importers.".".{bucket}` shape (lockfileVersion 9+) first, falling
/// back to the flat `{bucket}` shape a non-workspace lockfileVersion 6 (and earlier) project
/// uses. `package_json_dir` is where alef wrote the manifest, so if `pnpm-lock.yaml` sits beside
/// it at all, that lock's own root importer key is always `.` -- there is no ambiguity to resolve
/// there, only which of the two on-disk layouts this particular pnpm version chose. A lockfile
/// this reader does not recognize (an importer keyed by something other than `.`, or truly no
/// `dependencies`/`devDependencies` at all) yields an empty map, which is safe by construction:
/// [`stale_node_lock_findings`]'s one-sided rule treats "absent from this map" identically to
/// "name not in the lock", never as a contradiction. ~keep
fn locked_node_specifiers(lock: &serde_json::Value, bucket: &str) -> BTreeMap<String, String> {
    let table = lock
        .get("importers")
        .and_then(|importers| importers.get("."))
        .and_then(|root| root.get(bucket))
        .or_else(|| lock.get(bucket))
        .and_then(serde_json::Value::as_object);
    let Some(table) = table else {
        return BTreeMap::new();
    };
    let mut specifiers = BTreeMap::new();
    for (name, value) in table {
        // ~keep Every lockfileVersion that records a `specifier` field at all (5.4+, which
        // covers both the 6 and 9 shapes this reader targets) puts the package.json text here
        // verbatim; a bare `name: version` entry from an older lockfile has no `specifier` key
        // and is silently skipped rather than misread as a version-only requirement.
        let Some(specifier) = value.get("specifier").and_then(serde_json::Value::as_str) else {
            continue;
        };
        specifiers.insert(name.to_string(), specifier.to_string());
    }
    specifiers
}

/// Whether `specifier` is a form a direct text comparison against the lock's recorded specifier
/// can safely judge.
///
/// ~keep Excluded: `npm:` aliases (the lock records the aliased package's own specifier, not this
/// one), `workspace:` and `catalog:` (resolved through a workspace root this check never reads),
/// `file:`/`link:` (see `src/snippets/session/fingerprint.rs`'s module doc: a locally linked
/// dependency's resolved content, and potentially its recorded specifier text, can move for
/// reasons a text diff here cannot verify), git specifiers in every spelling pnpm accepts (a git
/// dependency can gain a resolved commit or semver hint in the lock that was never in
/// package.json), and anything containing a bare `/` (a GitHub `owner/repo` shorthand or a local
/// path, neither a registry range). A mismatch in any of these forms is not reliable evidence of
/// drift, so the entry is skipped rather than risked as a false positive.
fn is_checkable_node_specifier(specifier: &str) -> bool {
    let trimmed = specifier.trim();
    if trimmed.is_empty() {
        return false;
    }
    const UNCHECKABLE_PREFIXES: [&str; 8] = [
        "npm:",
        "workspace:",
        "catalog:",
        "file:",
        "link:",
        "git+",
        "git:",
        "github:",
    ];
    if UNCHECKABLE_PREFIXES.iter().any(|prefix| trimmed.starts_with(prefix)) {
        return false;
    }
    if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
        return false;
    }
    !trimmed.contains('/')
}

/// One `pyproject.toml` `[project.dependencies]` entry whose sibling `uv.lock` records a different
/// specifier for the same package.
///
/// Unlike [`StaleLockFinding`] there is no path-dependency walk (the requirement text and the
/// generated manifest are one file, same as the node check), and unlike [`StaleNodeLockFinding`]
/// the comparison is not against another copy of the manifest's own text -- it is against a
/// second, lock-recorded copy of that same text. `uv.lock` carries a `[package.metadata]
/// requires-dist` entry for the project's own lock-file package that is populated verbatim from
/// `pyproject.toml` at lock time (confirmed against astral-sh/uv#17549, which is exactly a report
/// of that recorded copy going stale relative to the manifest). That is uv's own invalidation
/// mechanism: `uv sync --locked` / `uv lock --check` fail with "The lockfile ... needs to be
/// updated" when the recorded copy no longer matches, regardless of whether the currently locked
/// *version* still happens to satisfy the manifest's range. That last clause is why this check
/// uses text equality (the node model) rather than semver-range satisfaction (the cargo model): an
/// open lower bound like `pyrefly>=1.1.1` is satisfied by a lock still pinning `1.1.1` a month
/// later even though uv itself would call that lock stale the moment anything forces a re-resolve
/// -- a range check would stay silent on exactly the drift this exists to catch, while a text
/// check catches it whenever the recorded copy itself has moved.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct StaleUvLockFinding {
    /// The committed `uv.lock` that contradicts the requirement.
    pub(crate) lock: PathBuf,
    /// The `pyproject.toml` alef generated the requirement in.
    pub(crate) declared_in: PathBuf,
    /// Package name as `pyproject.toml` spells it.
    pub(crate) dependency: String,
    /// The specifier text as written in `pyproject.toml` (empty when the dependency is
    /// unconstrained).
    pub(crate) requirement: String,
    /// The specifier text `uv.lock` records for the same name.
    pub(crate) locked_requirement: String,
}

/// Check every directory in which this run generated a `pyproject.toml` for a committed `uv.lock`
/// whose recorded specifiers disagree with it, returning the failure to record when one does.
///
/// A third sibling beside [`check_generated_lock_freshness`] and
/// [`check_generated_node_lock_freshness`], not a shared abstraction -- see the `~keep` comment on
/// the node check's doc comment for why forcing ecosystem-specific lock reading through one
/// function is how a later change to one silently drifts another.
/// Retained as the unmodified control the `_tolerating_pending_publish` variant is
/// differentially tested against: the pending-publish exemption is only meaningful if the
/// plain check still fails on the same input. No production call site remains. ~keep
#[cfg(test)]
pub(crate) fn check_generated_uv_lock_freshness(generated_paths: &HashSet<PathBuf>) -> Option<anyhow::Error> {
    check_generated_uv_lock_freshness_tolerating_pending_publish(generated_paths, None)
}

/// The shared collection step behind [`check_generated_uv_lock_freshness`] and
/// [`check_generated_uv_lock_freshness_tolerating_pending_publish`].
fn collect_generated_uv_lock_findings(generated_paths: &HashSet<PathBuf>) -> Vec<StaleUvLockFinding> {
    let mut directories = BTreeSet::new();
    for path in generated_paths {
        if path.file_name().and_then(|name| name.to_str()) != Some("pyproject.toml") {
            continue;
        }
        if let Some(dir) = path.parent() {
            directories.insert(dir.to_path_buf());
        }
    }
    let mut findings = Vec::new();
    for dir in &directories {
        findings.extend(stale_uv_lock_findings(dir));
    }
    tracing::debug!(
        manifest_dirs = directories.len(),
        findings = findings.len(),
        "checked generated pyproject.toml files against their committed uv.lock"
    );
    findings
}

/// Same check as [`check_generated_uv_lock_freshness`], except a finding fully explained by this
/// crate's own pending, not-yet-published registry-mode `test_apps` self-dependency is downgraded
/// to a `tracing::warn!` instead of failing the stage -- the uv sibling of
/// [`check_generated_lock_freshness_tolerating_pending_publish`]. See
/// [`registry_self_dependency`]'s doc for what "explained" means here and why it is deliberately
/// conservative, and [`crate::e2e::codegen::python::config::normalize_python_version`] for why the
/// requirement text needs PEP 508 normalization before comparison (the html-to-markdown incident
/// this closes: `test_apps/python/pyproject.toml` requires `html-to-markdown>=3.12.0` while PyPI
/// still only has `3.11.6` published).
pub(crate) fn check_generated_uv_lock_freshness_tolerating_pending_publish(
    generated_paths: &HashSet<PathBuf>,
    resolved_cfg: Option<&crate::core::config::ResolvedCrateConfig>,
) -> Option<anyhow::Error> {
    let findings = collect_generated_uv_lock_findings(generated_paths);
    if findings.is_empty() {
        return None;
    }
    let Some(self_dependency) = resolved_cfg.and_then(|cfg| {
        registry_self_dependency(
            cfg,
            "python",
            crate::e2e::codegen::python::config::normalize_python_version,
        )
    }) else {
        return Some(anyhow::anyhow!(stale_uv_lock_message(&findings)));
    };

    let (pending, real): (Vec<_>, Vec<_>) = findings.into_iter().partition(|finding| {
        finding.dependency == self_dependency.name && finding.requirement == self_dependency.requirement
    });

    if !pending.is_empty() {
        tracing::warn!(
            "{} committed uv.lock pin(s) below require this crate's own version, which is not on the \
             registry yet -- expected after a version bump; resolves once the release publishes:\n{}",
            pending.len(),
            stale_uv_lock_message(&pending)
        );
    }
    if real.is_empty() {
        None
    } else {
        Some(anyhow::anyhow!(stale_uv_lock_message(&real)))
    }
}

/// Every `[project.dependencies]` specifier declared in `pyproject_dir/pyproject.toml` that the
/// sibling `pyproject_dir/uv.lock` records a different specifier for.
///
/// Returns empty when either file is missing or unparseable: alef never authors a lockfile, so a
/// directory without one is a deliberate consumer choice, not a defect to report.
pub(crate) fn stale_uv_lock_findings(pyproject_dir: &Path) -> Vec<StaleUvLockFinding> {
    let manifest_path = pyproject_dir.join("pyproject.toml");
    let lock_path = pyproject_dir.join("uv.lock");
    if !manifest_path.is_file() {
        return Vec::new();
    }
    let Ok(manifest_text) = std::fs::read_to_string(&manifest_path) else {
        return Vec::new();
    };
    let Ok(manifest_toml) = toml::from_str::<toml::Value>(&manifest_text) else {
        return Vec::new();
    };
    let Ok(lock_text) = std::fs::read_to_string(&lock_path) else {
        return Vec::new();
    };
    let Ok(lock_toml) = toml::from_str::<toml::Value>(&lock_text) else {
        return Vec::new();
    };
    let Some(project) = manifest_toml.get("project") else {
        return Vec::new();
    };
    let Some(project_name) = project.get("name").and_then(toml::Value::as_str) else {
        return Vec::new();
    };
    let Some(dependencies) = project.get("dependencies").and_then(toml::Value::as_array) else {
        return Vec::new();
    };
    let locked = locked_uv_requirements(&lock_toml, project_name);
    if locked.is_empty() {
        return Vec::new();
    }
    // ~keep A name present in `[tool.uv.sources]` has its resolution overridden -- path, git, URL,
    // workspace member, or an explicit alternate index -- exactly the forms `parse_pep508_requirement`
    // below cannot see from the dependency string alone, since `render_pyproject`'s `Local` mode
    // writes the bare unconstrained name here and puts the actual source in this table. Comparing
    // any of these against the lock's registry-shaped specifier text would be a false positive.
    let overridden = uv_source_override_names(&manifest_toml);

    let mut findings = Vec::new();
    for entry in dependencies {
        let Some(raw) = entry.as_str() else { continue };
        let Some((name, requirement)) = parse_pep508_requirement(raw) else {
            continue;
        };
        let normalized = normalize_pep503_name(&name);
        if overridden.contains(&normalized) {
            continue;
        }
        // ~keep One-sided, matching the rule in `stale_lock_findings` and `stale_node_lock_findings`:
        // a name the lock's recorded copy never mentions is never reported. Here that also covers a
        // dependency `requires_dist_map` deliberately dropped for carrying a `marker`/`extra` key --
        // both mean this reader could not derive a single unconditional specifier for the name, which
        // is a reason to stay silent, not a reason to guess.
        let Some(locked_requirement) = locked.get(&normalized) else {
            continue;
        };
        if locked_requirement.trim() == requirement.trim() {
            continue;
        }
        findings.push(StaleUvLockFinding {
            lock: lock_path.clone(),
            declared_in: manifest_path.clone(),
            dependency: name,
            requirement,
            locked_requirement: locked_requirement.clone(),
        });
    }
    findings.sort_by(|left, right| left.dependency.cmp(&right.dependency));
    findings
}

/// `name -> specifier text` `uv.lock` recorded for the project's own dependencies.
///
/// Tries the project-lock shape first: the `[[package]]` entry whose (PEP 503 normalized) `name`
/// matches `pyproject.toml`'s own `[project.name]`, reading its `[package.metadata] requires-dist`.
/// Falls back to the standalone-script-lock shape's top-level `[manifest] requirements` -- the same
/// `{ name, specifier }` entries, just not attached to a per-package `[[package]]` table, because a
/// PEP 723 script lock has no installable project to attach metadata to. Alef only ever generates a
/// `pyproject.toml` with a real `[project]` table, so in practice the first shape is what fires; the
/// fallback exists so a `uv.lock` this reader does not fully control is read safely rather than
/// assumed. Either shape returning nothing yields an empty map, which is safe by construction:
/// [`stale_uv_lock_findings`]'s one-sided rule treats "absent from this map" identically to "name
/// not in the lock", never as a contradiction. ~keep
fn locked_uv_requirements(lock: &toml::Value, project_name: &str) -> BTreeMap<String, String> {
    let normalized_project = normalize_pep503_name(project_name);
    let root_requires_dist = lock
        .get("package")
        .and_then(toml::Value::as_array)
        .and_then(|packages| {
            packages.iter().find(|package| {
                package
                    .get("name")
                    .and_then(toml::Value::as_str)
                    .is_some_and(|name| normalize_pep503_name(name) == normalized_project)
            })
        })
        .and_then(|package| package.get("metadata"))
        .and_then(|metadata| metadata.get("requires-dist"))
        .and_then(toml::Value::as_array);
    if let Some(entries) = root_requires_dist {
        let map = requires_dist_map(entries);
        if !map.is_empty() {
            return map;
        }
    }
    lock.get("manifest")
        .and_then(|manifest| manifest.get("requirements"))
        .and_then(toml::Value::as_array)
        .map(|entries| requires_dist_map(entries))
        .unwrap_or_default()
}

/// `name -> specifier text` out of one `requires-dist` / `[manifest] requirements` array.
///
/// Skips any entry carrying a `marker` or `extra` key: those are conditionally-applicable copies
/// (a platform-gated dependency, or an optional-dependency-group member) that do not correspond
/// 1:1 with an unconditional `[project.dependencies]` entry, so including them risks comparing the
/// wrong recorded copy against the manifest.
fn requires_dist_map(entries: &[toml::Value]) -> BTreeMap<String, String> {
    let mut map = BTreeMap::new();
    for entry in entries {
        let Some(table) = entry.as_table() else { continue };
        if table.contains_key("marker") || table.contains_key("extra") {
            continue;
        }
        let Some(name) = table.get("name").and_then(toml::Value::as_str) else {
            continue;
        };
        let specifier = table.get("specifier").and_then(toml::Value::as_str).unwrap_or("");
        map.insert(normalize_pep503_name(name), specifier.to_string());
    }
    map
}

/// Names declared in `[tool.uv.sources]`, PEP 503 normalized.
///
/// Every entry in this table overrides where uv resolves that name from -- see the `~keep` comment
/// at its call site in `stale_uv_lock_findings`.
fn uv_source_override_names(manifest_toml: &toml::Value) -> HashSet<String> {
    manifest_toml
        .get("tool")
        .and_then(|tool| tool.get("uv"))
        .and_then(|uv| uv.get("sources"))
        .and_then(toml::Value::as_table)
        .map(|table| table.keys().map(|name| normalize_pep503_name(name)).collect())
        .unwrap_or_default()
}

/// Split a PEP 508 dependency string into `(name, specifier)`, or `None` when the form is not one
/// this reader can safely compare.
///
/// ~keep Excluded: an environment marker (`;`) makes the requirement conditional on something this
/// reader does not evaluate; a direct reference (`@`, a URL or local path) is pinned by content, not
/// by a registry range; extras (`[...]`) change what the name resolves to without changing the name
/// text itself, and this reader does not need to parse them correctly to know it should not compare
/// them. A form outside all three is a bare `name` optionally followed by a version specifier, which
/// is exactly what `requires_dist_map` also expects.
fn parse_pep508_requirement(raw: &str) -> Option<(String, String)> {
    let trimmed = raw.trim();
    if trimmed.is_empty() || trimmed.contains(';') || trimmed.contains('@') || trimmed.contains('[') {
        return None;
    }
    let name_len = trimmed
        .find(|character: char| !(character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')))
        .unwrap_or(trimmed.len());
    if name_len == 0 {
        return None;
    }
    let (name, specifier) = trimmed.split_at(name_len);
    Some((name.to_string(), specifier.trim().to_string()))
}

/// PEP 503 name normalization: lowercase, with every run of `-`, `_`, `.` collapsed to one `-`.
/// `pyproject.toml` and `uv.lock` are not guaranteed to spell the same package identically (`uv`
/// itself accepts `pytest_asyncio` and `pytest-asyncio` as the same dependency), so every name
/// compared or used as a map key in this module goes through this first.
fn normalize_pep503_name(name: &str) -> String {
    let mut normalized = String::with_capacity(name.len());
    let mut previous_was_separator = false;
    for character in name.chars() {
        if matches!(character, '-' | '_' | '.') {
            if !previous_was_separator {
                normalized.push('-');
            }
            previous_was_separator = true;
        } else {
            normalized.push(character.to_ascii_lowercase());
            previous_was_separator = false;
        }
    }
    normalized
}

/// Render the operator-facing failure: what disagrees, where each side said it, and the command
/// that reconciles them.
///
/// Reported, never rewritten: generation itself succeeded and alef does not author lockfiles,
/// so the fix is a command the operator runs in their own tree. ~keep
fn stale_uv_lock_message(findings: &[StaleUvLockFinding]) -> String {
    let mut message = format!(
        "{} committed uv.lock specifier(s) disagree with a pyproject.toml alef generated; `uv sync \
         --locked` (and frozen-lockfile CI jobs) will fail with \"The lockfile at `uv.lock` needs to be \
         updated\":",
        findings.len()
    );
    for finding in findings {
        message.push_str(&format!(
            "\n  - {}: `{}` is required as `{}` by {}, but the lock records `{}`. Fix with: uv lock \
             --project {}",
            finding.lock.display(),
            finding.dependency,
            finding.requirement,
            finding.declared_in.display(),
            finding.locked_requirement,
            finding.lock.parent().unwrap_or(Path::new(".")).display(),
        ));
    }
    message.push_str(
        "\nA pin held back on purpose belongs in pyproject.toml -- a lockfile cannot record an exception \
         to its own resolution.",
    );
    message
}

/// Render the operator-facing failure: what disagrees, where each side said it, and the command
/// that reconciles them.
///
/// Reported, never rewritten: generation itself succeeded and alef does not author lockfiles,
/// so the fix is a command the operator runs in their own tree. ~keep
fn stale_node_lock_message(findings: &[StaleNodeLockFinding]) -> String {
    let mut message = format!(
        "{} committed pnpm-lock.yaml specifier(s) disagree with a package.json alef generated; `pnpm \
         install --frozen-lockfile` (the CI default) will fail with ERR_PNPM_OUTDATED_LOCKFILE:",
        findings.len()
    );
    for finding in findings {
        message.push_str(&format!(
            "\n  - {}: `{}` is `{}` in {} ({}), but the lock records `{}`. Fix with: pnpm install \
             --lockfile-only -C {}",
            finding.lock.display(),
            finding.dependency,
            finding.requirement,
            finding.declared_in.display(),
            finding.bucket,
            finding.locked_requirement,
            finding.lock.parent().unwrap_or(Path::new(".")).display(),
        ));
    }
    message.push_str(
        "\nA pin held back on purpose belongs in package.json -- a lockfile cannot record an exception \
         to its own resolution.",
    );
    message
}

#[cfg(test)]
#[path = "lock_freshness_tests.rs"]
mod tests;