brink-analyzer 0.0.17

Cross-file semantic analysis for inkle's ink narrative scripting language
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
//! Import well-formedness + cross-module visibility enforcement (M-2,
//! docs/modules-spec.md §2/§4/§7).
//!
//! Runs in the whole-project pass, where every file's `IMPORT` list (HIR)
//! and the merged symbol index (each [`SymbolInfo`] now carrying its module
//! and effective visibility, §4) are both available. Four jobs:
//!
//! - **Import well-formedness**: self-import (`E090`), a name brought into
//!   scope twice (`E089`), and a bare `IMPORT { name } FROM mod` whose
//!   trailing segment names neither an item the (declared) module publicly
//!   exports nor a declared submodule of it (`E088`, dual-reading — see
//!   `known_module_names` and issue #1592).
//! - **Cross-module visibility**: a reference that resolves to a `#@private`
//!   definition outside the referrer's module is `E087`. Private is
//!   module-internal; the check keys off *visibility*, so it fires for a
//!   `#@private` def in an undeclared file referenced from another file just
//!   as for a declared module — but never for the pre-modules world, where
//!   every definition is `Public` (declaration-flips-default, §4).
//! - **Import-required resolution** (M-2c, §2 — "names cross module
//!   boundaries only via import"): a reference that resolves to a *public*
//!   definition in another **declared** module which the referring file did
//!   not `IMPORT` is `E025` (did-you-mean-`IMPORT` flavor). The restriction
//!   is keyed on the *target's* module being declared, so it never fires for
//!   the undeclared legacy soup — a plain multi-file `INCLUDE` project (no
//!   `#@module`) is one big default-public module and every cross-file bare
//!   reference keeps resolving byte-identically (§3). Only genuinely
//!   multi-*declared*-module projects are constrained.
//! - **Qualified ambiguity** (`E091`, §2): a `IMPORT mod` (qualified) whose
//!   module name also names a definition visible bare in the same file — so
//!   `mod.y` could mean either module-qualified access or field/member
//!   access on the definition. Fixed with an alias; flagged at the import.
//!
//! Compat: this pass only ever *adds* diagnostics, and every trigger
//! requires a `#@module`/`#@private`/`#@public`/`IMPORT` construct that no
//! strict-ink or existing brink-tier1 story contains — so the oracle and
//! tier1 corpus see nothing. In particular the `E025` import-required check
//! keys off a **declared** target module, absent from the entire pre-modules
//! corpus, so resolution stays byte-identical there.

use std::collections::{BTreeMap, BTreeSet};

use brink_ir::{
    Diagnostic, DiagnosticCode, FileId, HirFile, ResolutionMap, SymbolIndex, SymbolInfo,
    SymbolKind, Visibility,
};

/// The importable top-level kinds (modules-spec §2): "all top-level public
/// definitions — knots, functions, VARs, CONSTs, LISTs, STRUCTs". Stitches
/// are reachable only through the qualified form, so they are not part of
/// the bare-import export set validated here.
fn is_importable(kind: SymbolKind) -> bool {
    matches!(
        kind,
        SymbolKind::Knot
            | SymbolKind::Variable
            | SymbolKind::Constant
            | SymbolKind::List
            | SymbolKind::Struct
    )
}

/// Per-file declared module name (`Some` only for a *declared* module,
/// shared across a multi-file module; `None` for an undeclared
/// stem-module), plus the public top-level exports per declared module (for
/// bare-import validation).
///
/// The module map is derived primarily from any *top-level* symbol the file
/// declares — every top-level symbol in a file shares that file's module by
/// construction — with a fallback to the file's own HIR `#@module(…)`
/// declaration for a file that declares no top-level symbols of its own
/// (only root content), which otherwise never appears in `index.symbols`
/// and would wrongly resolve to "no module" (`None`).
///
/// Locals (`Param`/`Temp`) are skipped: they carry `module: None` by design
/// (module-internal, never module-qualified — see `insert_local`), and
/// `index.symbols` is a `HashMap` whose iteration order is nondeterministic,
/// so a local iterated before the file's top-level symbols would randomly
/// poison the attribution to `None` and fire `E087` on same-module
/// self-references (issue #795).
fn file_modules_and_exports(
    files: &[(FileId, &HirFile)],
    index: &SymbolIndex,
) -> (
    BTreeMap<FileId, Option<String>>,
    BTreeMap<String, BTreeSet<String>>,
) {
    let mut file_module: BTreeMap<FileId, Option<String>> = BTreeMap::new();
    let mut declared_exports: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
    for info in index.symbols.values() {
        // Locals never carry a module — attributing a file from one would
        // be order-dependent and wrong (issue #795, doc above).
        if matches!(info.kind, SymbolKind::Param | SymbolKind::Temp) {
            continue;
        }
        file_module.entry(info.file).or_insert(info.module.clone());
        if let Some(module) = &info.module
            && info.visibility == Visibility::Public
            && is_importable(info.kind)
        {
            declared_exports
                .entry(module.clone())
                .or_default()
                .insert(info.name.clone());
        }
    }
    for &(file_id, hir) in files {
        file_module
            .entry(file_id)
            .or_insert_with(|| hir.module.as_ref().map(|decl| decl.name.clone()));
    }
    (file_module, declared_exports)
}

/// Every module *name or name-prefix* this whole-project pass has real
/// visibility into (issue #1592): every module some file actually declares
/// itself as (`file_module`'s `Some` values), plus every `::`-joined
/// ancestor of those names.
///
/// The ancestor closure is the fix for the original silent no-op: a
/// directory that holds a declared submodule but is never itself the
/// module of any file (`story::market`, a pure container for
/// `story::market::barter`) never appears in `file_module` — nothing is
/// literally "module `story::market`" — so without this closure a bare
/// `use story::market::barter;` naming that container as `import.module`
/// could never be validated at all (neither confirmed nor refuted), and
/// `E088` stayed silent forever. `#@module(...)` accepts any non-empty
/// string, `::`-joined or not (`hir::lower::directive::module_directive_name`
/// places no structural constraint on it, and this crate's own
/// `native_use_dual_reading.rs` fixture declares
/// `#@module(story::market::barter)` from an `.ink` file), so this closure is
/// not an ink-specific no-op — a `::`-joined `#@module` fixture exercises it
/// exactly as a native `use` path does. The reason the oracle/tier1 corpus is
/// unaffected is the one stated in this module's top-level Compat doc: no
/// `#@module`/`IMPORT`/`use` construct appears anywhere in that corpus at
/// all, not any structural property of ink module names.
fn known_module_names(file_module: &BTreeMap<FileId, Option<String>>) -> BTreeSet<String> {
    let mut known = BTreeSet::new();
    for module in file_module.values().flatten() {
        known.insert(module.clone());
        let mut segments: Vec<&str> = module.split("::").collect();
        while segments.len() > 1 {
            segments.pop();
            known.insert(segments.join("::"));
        }
    }
    known
}

/// Is the referring file inside the target's module?
///
/// For a **declared** target module, "same declared module name"; for an
/// undeclared stem-module (`None`), "the same file" (each undeclared file is
/// its own singleton module). Shared by the E087 and E025 cross-module
/// checks so they agree on the module boundary.
fn referrer_in_target_module(
    file_module: &BTreeMap<FileId, Option<String>>,
    target: &SymbolInfo,
    ref_file: FileId,
) -> bool {
    match &target.module {
        Some(tmod) => file_module.get(&ref_file).and_then(Option::as_ref) == Some(tmod),
        None => ref_file == target.file,
    }
}

/// Per-file import coverage: the set of modules imported qualified
/// (`IMPORT mod`) and the set of `(module, source_name)` pairs brought in by
/// bare imports (`IMPORT { name } FROM mod`). Together these decide whether a
/// cross-module public reference is licensed (M-2c, §2).
///
/// Keyed by [`FileId`]; a file with no imports simply has no entry.
/// `BTreeMap`/`BTreeSet` throughout — nothing here iterates in a way that
/// feeds output ordering, but the deterministic containers keep the pass
/// order-insensitive by construction.
type ImportCoverage<'a> = (
    BTreeMap<FileId, BTreeSet<String>>,
    BTreeMap<FileId, BTreeSet<(&'a str, &'a str)>>,
);

fn import_coverage<'a>(files: &'a [(FileId, &'a HirFile)]) -> ImportCoverage<'a> {
    let mut qualified: BTreeMap<FileId, BTreeSet<String>> = BTreeMap::new();
    let mut bare: BTreeMap<FileId, BTreeSet<(&str, &str)>> = BTreeMap::new();
    for &(file_id, hir) in files {
        // Shared with `ImportScope` (`resolve.rs`) so resolution and this
        // E025 gate can never diverge on what an import covers (issue #790
        // review).
        let (file_qualified, file_bare) = crate::resolve::import_coverage_for_file(&hir.imports);
        if !file_qualified.is_empty() {
            qualified.insert(file_id, file_qualified);
        }
        if !file_bare.is_empty() {
            bare.insert(file_id, file_bare);
        }
    }
    (qualified, bare)
}

/// Does `ref_file` import `name` from declared module `module` — either by
/// bare-importing that exact name from it, or by importing the module
/// qualified (which licenses `module.name` access to any of its exports)?
fn import_covers(
    qualified: &BTreeMap<FileId, BTreeSet<String>>,
    bare: &BTreeMap<FileId, BTreeSet<(&str, &str)>>,
    ref_file: FileId,
    module: &str,
    name: &str,
) -> bool {
    if qualified
        .get(&ref_file)
        .is_some_and(|mods| mods.contains(module))
    {
        return true;
    }
    bare.get(&ref_file)
        .is_some_and(|pairs| pairs.contains(&(module, name)))
}

/// Is there a definition named `name` visible **bare** in `file_id` — a
/// top-level symbol in this file's own module? Used to detect the
/// qualified-import ambiguity (`E091`): a `IMPORT mod` collides when `mod` is
/// also such a definition.
///
/// Deterministic: `by_name` is a direct keyed lookup, and the candidate id
/// list is scanned membership-only (no order-dependent output).
fn symbol_visible_bare_in_file(
    index: &SymbolIndex,
    file_module: &BTreeMap<FileId, Option<String>>,
    file_id: FileId,
    name: &str,
) -> bool {
    let Some(ids) = index.by_name.get(name) else {
        return false;
    };
    ids.iter().any(|id| {
        index.symbols.get(id).is_some_and(|info| {
            // Bare-visible definitions are ordinary top-level names in the
            // same module; locals never participate in qualified access.
            !matches!(info.kind, SymbolKind::Param | SymbolKind::Temp)
                && referrer_in_target_module(file_module, info, file_id)
        })
    })
}

/// Every (file, range) the project's cross-file conventions injection
/// (issue #2289) rewrote into a call against a handler declared in
/// *another* file — [`brink_ir::ElementMatch::injected`], keyed by the
/// exact range `brink_ir::hir::lower_native::element::try_claim` gives the
/// rewritten `Expr::Call`'s `Path`, which by the `ResolvedRef::range`
/// contract (issue #1561, `brink_ir::symbols::index::ResolvedRef`'s own
/// doc) is also the resulting resolved reference's own range — so this map
/// is an exact-match lookup key for [`check_cross_module_refs`], with no
/// new identifier needed on either side.
///
/// Built from `(u32, u32)` pairs rather than `TextRange` itself:
/// `text_size::TextRange` derives neither `Ord` nor `PartialOrd`, so it
/// cannot key a `BTreeSet` directly — the pair is exactly its own
/// `(start, end)` and just as exact a key.
fn conventions_injected_call_ranges(
    files: &[(FileId, &HirFile)],
) -> BTreeMap<FileId, BTreeSet<(u32, u32)>> {
    let mut ranges: BTreeMap<FileId, BTreeSet<(u32, u32)>> = BTreeMap::new();
    for &(file_id, hir) in files {
        for m in &hir.element_matches {
            if m.injected {
                ranges
                    .entry(file_id)
                    .or_default()
                    .insert((m.line.start().into(), m.line.end().into()));
            }
        }
    }
    ranges
}

/// Cross-module reference gating (`E087` private / `E025` import-required).
///
/// Every resolved reference whose target lies outside the referrer's module
/// is gated: a `#@private` target is `E087` unconditionally; a *public*
/// target in another **declared** module is `E025` unless the referring file
/// imported it (bare name from that module, or the module qualified). A
/// same-module reference, or a public target in the undeclared legacy soup
/// (`module == None`), is always bare-legal and never flagged (§2/§3).
///
/// This walks `resolutions` uniformly — `ResolvedRef` carries no
/// `RefKind`, only `(file, range, target)`, so every reference kind that
/// resolves to a non-local target is gated the same way regardless of the
/// syntax that produced it. Issue #2249's new `RefKind::Type` (a struct
/// field's or `VAR`/`CONST`/`temp` annotation's `Named` leaf,
/// `resolve::resolve_type_ref`) is therefore already in scope here — it
/// resolves to the same `SymbolKind::Struct` target a construction
/// literal's `RefKind::Struct` reference does (issue #2246, the first
/// reference kind to reach this gate for a struct target), so a
/// project-file annotation naming an unimported public struct in another
/// declared module now raises `E025` here too, and a `#@private` one raises
/// `E087` — exercised in `type_annotation_reference_to_*` below.
///
/// **Issue #2289's exemption**: a reference whose `(file, range)` appears in
/// [`conventions_injected_call_ranges`] is skipped before the visibility
/// switch below, regardless of which arm it would otherwise take. Such a
/// reference is not a user-authored cross-module access at all — it is the
/// compiler's own rewrite of a matched prose line into a call against the
/// project's *configured* conventions module, sanctioned by the very
/// `brink.toml` `[project] conventions` pointer that caused the injection
/// (docs/decision-log.md 2026-08-05: "it's never file local … that's why
/// they're conventions and not 'local patterns'"). Requiring `pub` on every
/// claiming handler plus a `use` in every claiming file would reintroduce
/// exactly the file-local opt-in that ruling rejects.
fn check_cross_module_refs(
    files: &[(FileId, &HirFile)],
    index: &SymbolIndex,
    resolutions: &ResolutionMap,
    file_module: &BTreeMap<FileId, Option<String>>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    let (qualified_imports, bare_imports) = import_coverage(files);
    let injected_calls = conventions_injected_call_ranges(files);

    for r in resolutions {
        let Some(target) = index.symbols.get(&r.target) else {
            continue;
        };
        // Locals are always same-file and module-internal — never a
        // cross-module concern.
        if matches!(target.kind, SymbolKind::Param | SymbolKind::Temp) {
            continue;
        }
        // A same-module reference is always bare-legal (§2) — nothing to
        // enforce.
        if referrer_in_target_module(file_module, target, r.file) {
            continue;
        }
        // Issue #2289: a cross-file conventions injection's own rewritten
        // call is exempt — see this function's own doc.
        if injected_calls
            .get(&r.file)
            .is_some_and(|s| s.contains(&(r.range.start().into(), r.range.end().into())))
        {
            continue;
        }
        match target.visibility {
            // A `#@private` def referenced from outside its module: E087,
            // regardless of imports (private never crosses, §4).
            Visibility::Private => {
                diagnostics.push(Diagnostic {
                    file: r.file,
                    range: r.range,
                    message: format!("{}: `{}`", DiagnosticCode::E087.title(), target.name),
                    code: DiagnosticCode::E087,
                });
            }
            // A *public* def in another **declared** module (M-2c, §2): a
            // reference is legal only if this file imported it. Undeclared
            // target modules (`None`) are the permeable legacy soup and are
            // never gated, keeping the pre-modules corpus byte-identical.
            Visibility::Public => {
                if let Some(tmod) = &target.module
                    && !import_covers(
                        &qualified_imports,
                        &bare_imports,
                        r.file,
                        tmod,
                        &target.name,
                    )
                {
                    diagnostics.push(Diagnostic {
                        file: r.file,
                        range: r.range,
                        // Deliberately dialect-blind (issue #1590 companion
                        // finding): this pass reads only HIR, which never
                        // carries a native/ink frontend tag ("no dialect tag
                        // near HIR" — see `brink-db`'s `file_language` doc),
                        // so the message never spells out a concrete import
                        // statement — ink's `IMPORT { name } FROM mod` and
                        // native's `use mod::name;` differ. A consumer that
                        // *does* know the referring file's dialect
                        // (`brink-ide::import_fix::import_actions`, via
                        // `ProjectDb::is_native`) renders the concrete
                        // quick-fix syntax instead.
                        message: format!(
                            "unresolved cross-module reference `{name}` — import it from `{module}` (see modules-spec §2)",
                            name = target.name,
                            module = tmod,
                        ),
                        code: DiagnosticCode::E025,
                    });
                }
            }
        }
    }
}

/// Qualified module-vs-definition ambiguity (`E091`, §2): a `IMPORT mod`
/// (qualified) whose module name also names a definition visible bare in the
/// same file makes `mod.y` ambiguous. Flagged at the import's module token.
fn check_qualified_ambiguity(
    files: &[(FileId, &HirFile)],
    index: &SymbolIndex,
    file_module: &BTreeMap<FileId, Option<String>>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for &(file_id, hir) in files {
        for import in &hir.imports {
            // Only the qualified form (`IMPORT mod`) introduces a module name
            // into value position where it can collide with a definition.
            if import.bare {
                continue;
            }
            if symbol_visible_bare_in_file(index, file_module, file_id, &import.module) {
                diagnostics.push(Diagnostic {
                    file: file_id,
                    range: import.module_range,
                    message: format!("{}: `{}`", DiagnosticCode::E091.title(), import.module),
                    code: DiagnosticCode::E091,
                });
            }
        }
    }
}

/// Run the M-2 import + visibility checks.
#[must_use]
pub fn check(
    files: &[(FileId, &HirFile)],
    index: &SymbolIndex,
    resolutions: &ResolutionMap,
) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();

    let (file_module, declared_exports) = file_modules_and_exports(files, index);
    let known_modules = known_module_names(&file_module);

    check_cross_module_refs(files, index, resolutions, &file_module, &mut diagnostics);
    check_qualified_ambiguity(files, index, &file_module, &mut diagnostics);

    // ── Import well-formedness (E088/E089/E090) ─────────────────────
    for &(file_id, hir) in files {
        if hir.imports.is_empty() {
            continue;
        }
        let own_module = file_module.get(&file_id).and_then(Option::clone);
        let mut seen_locals: BTreeSet<String> = BTreeSet::new();
        let mut seen_modules: BTreeSet<String> = BTreeSet::new();

        for import in &hir.imports {
            if import.bare {
                for item in &import.items {
                    // Duplicate local name across this file's imports.
                    if !seen_locals.insert(item.local_name().to_string()) {
                        diagnostics.push(Diagnostic {
                            file: file_id,
                            range: item.range,
                            message: format!(
                                "{}: `{}`",
                                DiagnosticCode::E089.title(),
                                item.local_name()
                            ),
                            code: DiagnosticCode::E089,
                        });
                    }

                    // Dual-reading (issue #1592): the trailing segment
                    // `item.name` may resolve as an item `import.module`
                    // publicly exports, or as a declared submodule
                    // `import.module::item.name` in its own right (Rust's
                    // `use` dual-reads its trailing segment; charter §13.2
                    // commits to that lineage). Both readings are checked
                    // independently and BOTH may hold at once — no
                    // precedence is needed between them (decided +
                    // documented at `resolve::import_coverage_for_file`,
                    // which is where the "resolves to a module" reading is
                    // actually *licensed*). This check only fires when
                    // NEITHER reading resolves.
                    let is_item = declared_exports
                        .get(&import.module)
                        .is_some_and(|exports| exports.contains(&item.name));
                    let full_path = format!("{}::{}", import.module, item.name);
                    let is_module = known_modules.contains(&full_path);

                    // Self-import via the prefix (review finding #1686,
                    // 2026-07-27): `own_module == import.module` is only a
                    // genuine self-import when this trailing segment does
                    // NOT itself resolve as a declared submodule. When it
                    // does (`is_module`), `import.module` is the
                    // *importing file's own module* legitimately importing
                    // one of its own declared **child** submodules
                    // (`story::market` writing `use story::market::barter;`
                    // to license qualified access to `barter`'s exports,
                    // e.g. `barter::haggle` — never bare `haggle`) —
                    // required by the E025 import-required gate, not a
                    // self-import. That
                    // shape gets its own full-path check right below,
                    // exactly where it belongs; checked per item (not once
                    // per import) because whether it applies depends on
                    // this item's own dual-reading verdict.
                    if !is_module && own_module.as_deref() == Some(import.module.as_str()) {
                        diagnostics.push(Diagnostic {
                            file: file_id,
                            range: import.module_range,
                            message: format!(
                                "{}: `{}`",
                                DiagnosticCode::E090.title(),
                                import.module
                            ),
                            code: DiagnosticCode::E090,
                        });
                    }

                    // A trailing segment that resolves as a module names
                    // that module — from *this* declaration's own module,
                    // that is a self-import exactly as the qualified form's
                    // check above, just reached through the item-leaf
                    // shape (`use story::market::barter;` from inside
                    // `story::market::barter` itself).
                    if is_module && own_module.as_deref() == Some(full_path.as_str()) {
                        diagnostics.push(Diagnostic {
                            file: file_id,
                            range: item.range,
                            message: format!("{}: `{}`", DiagnosticCode::E090.title(), full_path),
                            code: DiagnosticCode::E090,
                        });
                    }

                    // Aliased trailing module segment (review finding #1686,
                    // 2026-07-27): `use story::market::barter as b;` where
                    // `barter` resolves as a **module**, not an item, has no
                    // representation to alias — `ImportItem.alias` renames
                    // one local binding, but a licensed module contributes
                    // its whole (unbounded, project-wide-determined) export
                    // set under their own names; there is no field to carry
                    // "these exports now come in under `b`" instead. Before
                    // this check, the alias was silently ignored: the
                    // submodule's exports remained reachable via qualified
                    // access under their original name (e.g. `barter::x`,
                    // never bare) (the phantom `module::item` candidate in
                    // `resolve::import_coverage_for_file` does not know
                    // about aliases at all), while `b` bound
                    // nothing — with no diagnostic anywhere. This is the
                    // same "no `Import` shape for aliasing a whole module"
                    // gap `lower_native::import::lower_use_decl` already
                    // rejects loudly for the single-segment form
                    // (`use a as m;` → `E129`); reused here because it is
                    // structurally the same defect, only knowable once
                    // whole-project module data resolves the dual-reading
                    // (which is why it can't be caught at lowering time).
                    if is_module && item.alias.is_some() {
                        diagnostics.push(Diagnostic {
                            file: file_id,
                            range: item.range,
                            message: format!(
                                "{}: cannot alias imported module `{}`",
                                DiagnosticCode::E129.title(),
                                full_path
                            ),
                            code: DiagnosticCode::E129,
                        });
                    }

                    // Unresolved import: the trailing segment names neither
                    // a public export of the *declared* module `import.module`
                    // nor a declared submodule of it. Only checked when this
                    // pass has real visibility into `import.module` (via
                    // `known_modules`, which — unlike the old
                    // `declared_exports`-only guard — also covers a
                    // container module with no items of its own, closing
                    // the original silent no-op). `known_modules` is a
                    // superset of `declared_exports`'s keys by construction
                    // (every exporting module is a `file_module` value), so
                    // this single check subsumes the old guard. An
                    // undeclared/unknown module's export set genuinely
                    // isn't visible here, so it stays unchecked rather than
                    // false-flagged.
                    if !is_item && !is_module && known_modules.contains(&import.module) {
                        diagnostics.push(Diagnostic {
                            file: file_id,
                            range: item.range,
                            message: format!(
                                "{}: `{}` from `{}`",
                                DiagnosticCode::E088.title(),
                                item.name,
                                import.module
                            ),
                            code: DiagnosticCode::E088,
                        });
                    }
                }
            } else {
                // Self-import: a module cannot import itself (qualified
                // form — the whole path always names the module, no
                // trailing-segment dual-reading applies here).
                if own_module.as_deref() == Some(import.module.as_str()) {
                    diagnostics.push(Diagnostic {
                        file: file_id,
                        range: import.module_range,
                        message: format!("{}: `{}`", DiagnosticCode::E090.title(), import.module),
                        code: DiagnosticCode::E090,
                    });
                }
                // Qualified form: a repeated `IMPORT mod` is a duplicate.
                if !seen_modules.insert(import.module.clone()) {
                    diagnostics.push(Diagnostic {
                        file: file_id,
                        range: import.module_range,
                        message: format!("{}: `{}`", DiagnosticCode::E089.title(), import.module),
                        code: DiagnosticCode::E089,
                    });
                }
            }
        }
    }

    diagnostics
}

#[cfg(test)]
mod tests {
    use brink_format::{DefinitionId, DefinitionTag};
    use brink_ir::{
        Block, DiagnosticCode, FileId, HirFile, Import, ImportItem, ModuleDecl, ResolvedRef, Scope,
        SymbolIndex, SymbolInfo, SymbolKind, Visibility,
    };
    use rowan::{TextRange, TextSize};

    use super::check;

    fn range(offset: u32, len: u32) -> TextRange {
        TextRange::new(TextSize::new(offset), TextSize::new(offset + len))
    }

    fn hir_with_module(name: &str) -> HirFile {
        hir_with_module_and_imports(name, Vec::new())
    }

    fn hir_with_module_and_imports(name: &str, imports: Vec<Import>) -> HirFile {
        HirFile {
            root_content: Block::default(),
            knots: Vec::new(),
            variables: Vec::new(),
            constants: Vec::new(),
            lists: Vec::new(),
            structs: Vec::new(),
            externals: Vec::new(),
            includes: Vec::new(),
            module: Some(ModuleDecl {
                name: name.to_string(),
                range: range(0, 1),
                was: None,
            }),
            imports,
            visibility: Vec::new(),
            was_directives: Vec::new(),
            allow_scopes: Vec::new(),
            element_matches: Vec::new(),
            cue_names: Vec::new(),
            native: false,
            claim_handlers: Vec::new(),
            dispatch_handlers: Vec::new(),
        }
    }

    /// A bare `use module::item;` / `IMPORT { item } FROM module` with no
    /// alias — the shape `#1592`'s dual-reading applies to.
    fn bare_import(module: &str, item: &str) -> Import {
        Import {
            module: module.to_string(),
            module_range: range(0, 1),
            items: vec![ImportItem {
                name: item.to_string(),
                alias: None,
                range: range(1, 1),
            }],
            bare: true,
            range: range(0, 2),
        }
    }

    /// The aliased form (`use module::item as alias;` /
    /// `IMPORT { item AS alias } FROM module`) — the shape the #1686 review
    /// found silently dropped an alias-of-module diagnostic.
    fn bare_import_with_alias(module: &str, item: &str, alias: &str) -> Import {
        Import {
            module: module.to_string(),
            module_range: range(0, 1),
            items: vec![ImportItem {
                name: item.to_string(),
                alias: Some(alias.to_string()),
                range: range(1, 1),
            }],
            bare: true,
            range: range(0, 2),
        }
    }

    fn symbol(
        id: DefinitionId,
        kind: SymbolKind,
        name: &str,
        module: Option<&str>,
        scope: Option<Scope>,
    ) -> SymbolInfo {
        SymbolInfo {
            kind,
            file: FileId(0),
            range: range(0, 1),
            id,
            name: name.to_string(),
            params: Vec::new(),
            detail: None,
            scope,
            param_detail: None,
            module: module.map(str::to_string),
            visibility: Visibility::Private,
        }
    }

    /// Issue #795: a single file declaring `#@module(quest)` whose knot
    /// references a sibling knot bare must produce zero `E087`, no matter
    /// which of the file's symbols `index.symbols` (a `HashMap` with
    /// nondeterministic iteration order) happens to yield first. Locals
    /// carry `module: None` by design; before the fix, a local iterated
    /// ahead of the file's top-level symbols randomly poisoned the file's
    /// module attribution to `None`, flagging every same-module
    /// self-reference. Repeated fresh-HashMap runs cover the order space.
    #[test]
    fn single_file_declared_module_self_reference_never_e087() {
        for _ in 0..64 {
            let hir = hir_with_module("quest");
            let files = [(FileId(0), &hir)];

            let knot_id = DefinitionId::new(DefinitionTag::Address, 1);
            let sibling_id = DefinitionId::new(DefinitionTag::Address, 2);
            let temp_id = DefinitionId::new(DefinitionTag::LocalVar, 3);

            // Fresh HashMaps each iteration — fresh RandomState, fresh order.
            let mut index = SymbolIndex::default();
            index.symbols.insert(
                knot_id,
                symbol(knot_id, SymbolKind::Knot, "caller", Some("quest"), None),
            );
            index.symbols.insert(
                sibling_id,
                symbol(sibling_id, SymbolKind::Knot, "sibling", Some("quest"), None),
            );
            index.symbols.insert(
                temp_id,
                symbol(
                    temp_id,
                    SymbolKind::Temp,
                    "g",
                    None,
                    Some(Scope {
                        knot: Some("caller".to_string()),
                        stitch: None,
                    }),
                ),
            );
            for (name, id) in [("caller", knot_id), ("sibling", sibling_id), ("g", temp_id)] {
                index.by_name.entry(name.to_string()).or_default().push(id);
            }

            // `caller` (file 0, module quest) references `sibling` bare —
            // same declared module, must never be gated.
            let resolutions = vec![ResolvedRef {
                file: FileId(0),
                range: range(10, 7),
                target: sibling_id,
            }];

            let diagnostics = check(&files, &index, &resolutions);
            assert!(
                diagnostics.is_empty(),
                "same-module self-reference must produce no diagnostics, got {diagnostics:?}"
            );
        }
    }

    /// Issue #1590 companion finding: this pass never sees a native/ink
    /// frontend tag (HIR is dialect-blind by design — "no dialect tag near
    /// HIR"), so the `E025` message must not spell out a concrete import
    /// statement's syntax — ink's `IMPORT { name } FROM mod` reads wrong to a
    /// native `.brink` author, and there is no signal here to pick the other
    /// spelling instead. The message stays syntax-free; a consumer that does
    /// know the referring file's dialect (`brink-ide::import_fix`, via
    /// `ProjectDb::is_native`) renders the concrete quick-fix text.
    #[test]
    fn e025_message_never_hardcodes_a_concrete_import_statement() {
        let quest = hir_with_module("quest");
        let town = hir_with_module("town");
        let files = [(FileId(0), &quest), (FileId(1), &town)];

        let ambush_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            ambush_id,
            SymbolInfo {
                visibility: Visibility::Public,
                ..symbol(ambush_id, SymbolKind::Knot, "ambush", Some("quest"), None)
            },
        );
        index
            .by_name
            .entry("ambush".to_string())
            .or_default()
            .push(ambush_id);

        // `town` references `quest`'s public `ambush` bare, with no IMPORT/
        // `use` at all — E025 must fire (this is the same well-established
        // gate `check_cross_module_refs` exercises elsewhere in this test
        // module and in `brink-ide`'s `import_fix` tests).
        let resolutions = vec![ResolvedRef {
            file: FileId(1),
            range: range(10, 6),
            target: ambush_id,
        }];

        let diagnostics = check(&files, &index, &resolutions);
        let e025: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E025)
            .collect();
        assert_eq!(e025.len(), 1, "expected exactly one E025: {diagnostics:?}");
        assert!(
            !e025[0].message.contains("IMPORT"),
            "E025 message must not hardcode ink's IMPORT syntax: {:?}",
            e025[0].message
        );
    }

    // ── PR #2271 review finding: issue #2249's new `RefKind::Type` ───────
    // reference resolves to a `SymbolKind::Struct` target exactly like a
    // construction literal's `RefKind::Struct` does (issue #2246) — and
    // `check_cross_module_refs` walks `resolutions` by target kind alone
    // (`ResolvedRef` carries no `RefKind`), so it is already in scope here.
    // These three model the resolution `resolve::resolve_type_ref` records
    // for a `VAR`/`CONST`/`temp` annotation or struct field naming a struct
    // in another declared module — the same hand-built-`SymbolIndex` idiom
    // `e025_message_never_hardcodes_a_concrete_import_statement` above uses
    // for a `Knot` target.

    /// An unimported, unqualified reference to a *public* struct in another
    /// declared module: `E025`, same as any other reference kind.
    #[test]
    fn type_annotation_reference_to_unimported_public_struct_in_another_module_is_e025() {
        let quest = hir_with_module("quest");
        let town = hir_with_module("town");
        let files = [(FileId(0), &quest), (FileId(1), &town)];

        let cue_id = DefinitionId::new(DefinitionTag::StructDef, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            cue_id,
            SymbolInfo {
                visibility: Visibility::Public,
                ..symbol(cue_id, SymbolKind::Struct, "Cue", Some("quest"), None)
            },
        );
        index
            .by_name
            .entry("Cue".to_string())
            .or_default()
            .push(cue_id);

        // `town`'s `~ temp c: Cue` (or a struct field `f: Cue`) resolves to
        // `quest`'s public `Cue` with no import — this is the shape
        // `resolve_type_ref` records for the `TYPE_EXPR`/`TYPE_NAME` range.
        let resolutions = vec![ResolvedRef {
            file: FileId(1),
            range: range(10, 3),
            target: cue_id,
        }];

        let diagnostics = check(&files, &index, &resolutions);
        assert!(
            diagnostics
                .iter()
                .any(|d| d.code == DiagnosticCode::E025 && d.file == FileId(1)),
            "unimported cross-module struct type annotation must raise E025: {diagnostics:?}"
        );
    }

    /// The same reference, but `town` has imported `Cue` from `quest`: no
    /// diagnostic.
    #[test]
    fn type_annotation_reference_to_imported_public_struct_in_another_module_is_silent() {
        let quest = hir_with_module("quest");
        let town = hir_with_module_and_imports("town", vec![bare_import("quest", "Cue")]);
        let files = [(FileId(0), &quest), (FileId(1), &town)];

        let cue_id = DefinitionId::new(DefinitionTag::StructDef, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            cue_id,
            SymbolInfo {
                visibility: Visibility::Public,
                ..symbol(cue_id, SymbolKind::Struct, "Cue", Some("quest"), None)
            },
        );
        index
            .by_name
            .entry("Cue".to_string())
            .or_default()
            .push(cue_id);

        let resolutions = vec![ResolvedRef {
            file: FileId(1),
            range: range(10, 3),
            target: cue_id,
        }];

        let diagnostics = check(&files, &index, &resolutions);
        assert!(
            diagnostics.is_empty(),
            "an imported cross-module struct type annotation must raise nothing: {diagnostics:?}"
        );
    }

    /// A reference to a `#@private` struct in another module: `E087`,
    /// unconditionally — imports never let a private definition cross a
    /// module boundary.
    #[test]
    fn type_annotation_reference_to_private_struct_in_another_module_is_e087() {
        let quest = hir_with_module("quest");
        let town = hir_with_module("town");
        let files = [(FileId(0), &quest), (FileId(1), &town)];

        let cue_id = DefinitionId::new(DefinitionTag::StructDef, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            cue_id,
            SymbolInfo {
                visibility: Visibility::Private,
                ..symbol(cue_id, SymbolKind::Struct, "Cue", Some("quest"), None)
            },
        );
        index
            .by_name
            .entry("Cue".to_string())
            .or_default()
            .push(cue_id);

        let resolutions = vec![ResolvedRef {
            file: FileId(1),
            range: range(10, 3),
            target: cue_id,
        }];

        let diagnostics = check(&files, &index, &resolutions);
        assert!(
            diagnostics
                .iter()
                .any(|d| d.code == DiagnosticCode::E087 && d.file == FileId(1)),
            "a private struct referenced cross-module in a type annotation must raise E087: \
             {diagnostics:?}"
        );
    }

    /// Review finding #1686 (E088 guard widening): swapping the old
    /// `declared_exports.get(&import.module).is_some()` guard for
    /// `known_modules.contains(&import.module)` (needed so a pure-directory
    /// prefix like `story::market` is checked at all, dual-reading's whole
    /// point) is broader than just that pure-directory case — `E088` now
    /// also fires for a bare import naming an item of a **declared module
    /// that exports nothing publicly at all** (every top-level symbol
    /// `#@private`/unmarked-native-default-private), where before this PR
    /// it was silent (no `declared_exports` entry to check against, exactly
    /// like the pure-directory case, but for a different underlying
    /// reason). Pinned here so this widening — real and intentional, since
    /// `known_modules` is the correct superset for the pure-directory case
    /// — doesn't silently drift further. `E088`'s own diagnostic title
    /// ("names a definition the declared module does not export") already
    /// reads correctly for this case: a private item genuinely is not
    /// exported, so no wording change is needed alongside the widening.
    #[test]
    fn bare_import_from_a_declared_module_with_no_public_exports_is_e088() {
        let vault = hir_with_module("vault");
        let main = hir_with_module_and_imports("main", vec![bare_import("vault", "secret")]);
        let files = [(FileId(0), &vault), (FileId(1), &main)];

        // `vault` declares `secret`, but it's Private — never a
        // `declared_exports` entry, so `is_item` is false. `secret` also
        // isn't itself a declared module, so `is_module` is false too.
        let secret_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            secret_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Private,
                ..symbol(secret_id, SymbolKind::Knot, "secret", Some("vault"), None)
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e088: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E088)
            .collect();
        assert_eq!(
            e088.len(),
            1,
            "a bare import from a declared module that exports nothing publicly must diagnose \
             E088 (the known_modules-superset widening), even though the named item genuinely \
             exists (just private): {diagnostics:?}"
        );
    }

    // ── Issue #1592: dual-reading `use`/`IMPORT` trailing segments ──────

    /// A `story::market` prefix with **no file of its own** — a pure
    /// directory holding the declared submodule `story::market::barter` —
    /// is exactly the original silent no-op's fixture: before #1592,
    /// `declared_exports.get("story::market")` was `None` (nothing exports
    /// *from* a module no file ever declares), so the `E088` check could
    /// never fire either way, whether `barter` was a real submodule or a
    /// typo. Dual-reading must resolve this trailing segment as the module
    /// `story::market::barter` and license it — no `E088`.
    #[test]
    fn dual_reading_trailing_segment_resolving_to_a_submodule_is_not_e088() {
        let barter = hir_with_module("story::market::barter");
        let main = hir_with_module_and_imports(
            "story::main",
            vec![bare_import("story::market", "barter")],
        );
        let files = [(FileId(0), &barter), (FileId(1), &main)];

        let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            haggle_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    haggle_id,
                    SymbolKind::Knot,
                    "haggle",
                    Some("story::market::barter"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e088: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E088)
            .collect();
        assert!(
            e088.is_empty(),
            "a trailing segment naming a real submodule must not be E088: {diagnostics:?}"
        );
    }

    /// The mirror of the previous test: the same container-only
    /// `story::market` prefix, but the trailing segment names neither a
    /// declared submodule nor an export — this is the case #1592 requires
    /// to newly diagnose (previously silent for exactly the same structural
    /// reason: `story::market` had no `declared_exports` entry to check
    /// against).
    #[test]
    fn dual_reading_trailing_segment_resolving_to_neither_is_e088() {
        let barter = hir_with_module("story::market::barter");
        let main = hir_with_module_and_imports(
            "story::main",
            vec![bare_import("story::market", "nonexistent")],
        );
        let files = [(FileId(0), &barter), (FileId(1), &main)];

        let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            haggle_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    haggle_id,
                    SymbolKind::Knot,
                    "haggle",
                    Some("story::market::barter"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e088: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E088)
            .collect();
        assert_eq!(
            e088.len(),
            1,
            "a trailing segment naming neither an export nor a submodule must diagnose (the \
             retired silent no-op): {diagnostics:?}"
        );
    }

    /// Precedence decision (#1592, "decide and document"): when a trailing
    /// segment resolves as **both** a declared item of the parent module
    /// *and* a declared submodule in its own right, neither reading is
    /// suppressed — no `E088` fires, because the check only fires when
    /// NEITHER reading holds. (The *licensing* half of "both apply" — that
    /// the submodule is also licensed for qualified access alongside the
    /// item (never bare) — is proved at the resolution level in
    /// `native_use_dual_reading.rs`'s
    /// `use_naming_a_module_does_not_license_bare_access_to_its_exports`,
    /// which is a whole-project concern this diagnostics-only pass can't
    /// observe.)
    #[test]
    fn dual_reading_both_item_and_submodule_neither_is_suppressed() {
        let market = hir_with_module("story::market");
        let barter = hir_with_module("story::market::barter");
        let main = hir_with_module_and_imports(
            "story::main",
            vec![bare_import("story::market", "barter")],
        );
        let files = [
            (FileId(0), &market),
            (FileId(1), &barter),
            (FileId(2), &main),
        ];

        let mut index = SymbolIndex::default();
        // `story::market` itself declares a public item literally named
        // `barter` — the "both" collision.
        let item_id = DefinitionId::new(DefinitionTag::Address, 1);
        index.symbols.insert(
            item_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    item_id,
                    SymbolKind::Knot,
                    "barter",
                    Some("story::market"),
                    None,
                )
            },
        );
        // `story::market::barter` is also a real, separately declared
        // submodule.
        let haggle_id = DefinitionId::new(DefinitionTag::Address, 2);
        index.symbols.insert(
            haggle_id,
            SymbolInfo {
                file: FileId(1),
                visibility: Visibility::Public,
                ..symbol(
                    haggle_id,
                    SymbolKind::Knot,
                    "haggle",
                    Some("story::market::barter"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e088: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E088)
            .collect();
        assert!(
            e088.is_empty(),
            "a trailing segment that resolves as both an item and a module must not diagnose: \
             {diagnostics:?}"
        );
    }

    /// The dual-reading module path also feeds self-import (`E090`): a file
    /// declaring `story::market::barter` that `use`s the leaf-item form of
    /// its own module (`use story::market::barter;`, parsed as
    /// `module: "story::market", items: [barter]`) is importing itself
    /// exactly as if it had written the qualified form directly — the
    /// pre-#1592 check only compared `own_module` against `import.module`
    /// (the *prefix*), so this shape was invisible to `E090` even though it
    /// is the same self-import.
    #[test]
    fn dual_reading_self_import_via_leaf_form_is_e090() {
        let barter = hir_with_module_and_imports(
            "story::market::barter",
            vec![bare_import("story::market", "barter")],
        );
        let files = [(FileId(0), &barter)];

        let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            haggle_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    haggle_id,
                    SymbolKind::Knot,
                    "haggle",
                    Some("story::market::barter"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e090: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E090)
            .collect();
        assert_eq!(
            e090.len(),
            1,
            "the leaf-item form naming this file's own module must self-import exactly as the \
             qualified form does: {diagnostics:?}"
        );
    }

    /// Review finding #1686 (BLOCKING E090 false positive): a **parent**
    /// module importing its own declared **child** submodule via the
    /// leaf-item shape (`use story::market::barter;` written from inside
    /// `story::market` itself, parsed as `module: "story::market", items:
    /// [barter]`) must NOT be flagged `E090` — this is exactly the import
    /// the `E025` import-required gate makes *mandatory* for
    /// `story::market` to reference `story::market::barter`'s exports via
    /// qualified access (e.g. `barter::haggle`), not a self-import. The
    /// pre-fix prefix check
    /// (`own_module == import.module`) could not distinguish this from a
    /// genuine self-import because it never consulted the item's own
    /// dual-reading verdict (`is_module`).
    #[test]
    fn parent_module_importing_its_own_declared_submodule_is_not_e090() {
        let barter = hir_with_module("story::market::barter");
        let market = hir_with_module_and_imports(
            "story::market",
            vec![bare_import("story::market", "barter")],
        );
        let files = [(FileId(0), &barter), (FileId(1), &market)];

        let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            haggle_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    haggle_id,
                    SymbolKind::Knot,
                    "haggle",
                    Some("story::market::barter"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        assert!(
            diagnostics.is_empty(),
            "a parent module importing its own declared child submodule must diagnose nothing \
             (no E090, and the submodule licenses `haggle` so no E088 either): {diagnostics:?}"
        );
    }

    /// Review finding #1686 (BLOCKING aliased trailing module segment): a
    /// trailing segment that both carries a local alias AND resolves as a
    /// declared **submodule** (`use story::market::barter as b;`) has no
    /// sound `Import` representation — aliasing an entire module's export
    /// set, not one name. Before this fix this was silently accepted:
    /// `story::market::barter`'s exports remained reachable via qualified
    /// access under their own name (the phantom module candidate ignores
    /// aliases) while `b` bound nothing, with no diagnostic anywhere.
    /// Mirrors
    /// `lower_native::import::lower_use_decl`'s `E129` for the
    /// single-segment `use a as m;` module-alias shape.
    #[test]
    fn aliased_trailing_segment_resolving_to_a_submodule_is_e129() {
        let barter = hir_with_module("story::market::barter");
        let main = hir_with_module_and_imports(
            "story::main",
            vec![bare_import_with_alias("story::market", "barter", "b")],
        );
        let files = [(FileId(0), &barter), (FileId(1), &main)];

        let haggle_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            haggle_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    haggle_id,
                    SymbolKind::Knot,
                    "haggle",
                    Some("story::market::barter"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e129: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E129)
            .collect();
        assert_eq!(
            e129.len(),
            1,
            "aliasing a trailing segment that resolves as a module must diagnose E129, not \
             silently drop the alias: {diagnostics:?}"
        );
    }

    /// Ink dialect regression (#1592 "tests in both dialects"): `quest`
    /// exports something, but never `ambush`, and no file anywhere declares
    /// a module named `quest::ambush` — so neither dual-reading path
    /// resolves. This is *not* because ink module names are structurally
    /// flat (`#@module(...)` accepts any non-empty string, `::`-joined or
    /// not — see `known_module_names`'s doc, corrected by the #1686 review);
    /// it is simply that this fixture's corpus never declares that
    /// submodule. `E088` must keep firing exactly as it did before dual-
    /// reading landed, for this genuinely-unexported name.
    #[test]
    fn unexported_import_still_diagnoses_e088_with_dual_reading_in_place() {
        let quest = hir_with_module("quest");
        let town = hir_with_module_and_imports("town", vec![bare_import("quest", "ambush")]);
        let files = [(FileId(0), &quest), (FileId(1), &town)];

        // `quest` exports something, but never `ambush`.
        let other_id = DefinitionId::new(DefinitionTag::Address, 1);
        let mut index = SymbolIndex::default();
        index.symbols.insert(
            other_id,
            SymbolInfo {
                file: FileId(0),
                visibility: Visibility::Public,
                ..symbol(
                    other_id,
                    SymbolKind::Knot,
                    "guard_talk",
                    Some("quest"),
                    None,
                )
            },
        );

        let diagnostics = check(&files, &index, &Vec::new());
        let e088: Vec<_> = diagnostics
            .iter()
            .filter(|d| d.code == DiagnosticCode::E088)
            .collect();
        assert_eq!(
            e088.len(),
            1,
            "an unexported ink import must still diagnose E088, unaffected by dual-reading: \
             {diagnostics:?}"
        );
    }
}