vize_canon 0.204.0

Canon - The standard of correctness for Vize type checking
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
use std::{
    path::{Path, PathBuf},
    process::{Command, Output},
};

use super::super::{Diagnostic, TypeCheckResult, VirtualProject};
use crate::batch::error::{CorsaError, CorsaResult};
use crate::batch::executor::diagnostics::{
    DiagnosticMapper, relative_module_resolves_on_disk, should_skip_diagnostic,
    should_skip_original_diagnostic,
};
use vize_carton::{FxHashMap, profile};
use vize_carton::{String, cstr};

pub(super) fn check_with_cli(
    corsa_path: &Path,
    project: &VirtualProject,
) -> CorsaResult<TypeCheckResult> {
    let config_path = project.virtual_root().join("tsconfig.json");
    run_cli_for_config(corsa_path, project, &config_path, Some(available_threads()))
}

fn available_threads() -> usize {
    std::thread::available_parallelism()
        .map(std::num::NonZero::get)
        .unwrap_or(1)
}

/// Run the project check sharded across `servers` concurrent Corsa CLI
/// processes. Corsa's own checker pool saturates around four cores, so on
/// wider machines a single process leaves most of the CPU idle; partitioning
/// the project along the connected components of its import graph restores
/// the parallelism while keeping each shard's program disjoint.
///
/// Ambient `.d.ts` files and sources carrying module/global declarations are
/// included in every shard so augmentations behave exactly as in the single
/// program. Each diagnostic is reported by the shard that owns its file, so
/// the merged result matches an unsharded run.
pub(super) fn check_with_cli_sharded(
    corsa_path: &Path,
    project: &VirtualProject,
    servers: usize,
) -> CorsaResult<TypeCheckResult> {
    let plan = partition_virtual_files(project, servers);
    if plan.shards.len() <= 1 {
        return check_with_cli(corsa_path, project);
    }

    let mut config_paths = Vec::with_capacity(plan.shards.len());
    for (index, shard) in plan.shards.iter().enumerate() {
        config_paths.push(profile!(
            "canon.corsa.cli.write_shard_tsconfig",
            project.write_shard_tsconfig(index, shard)
        )?);
    }

    let owners = &plan.owners;
    // Split the machine's checker budget across the concurrent programs.
    let checkers = (available_threads() / config_paths.len()).max(4);
    let results = profile!("canon.corsa.cli.sharded", {
        std::thread::scope(|scope| {
            let handles: Vec<_> = config_paths
                .iter()
                .map(|config_path| {
                    scope.spawn(move || {
                        run_cli_for_config(corsa_path, project, config_path, Some(checkers))
                    })
                })
                .collect();
            handles
                .into_iter()
                .map(|handle| {
                    handle.join().unwrap_or_else(|_| {
                        Err(CorsaError::CorsaExecution {
                            exit_code: -1,
                            message: "sharded corsa CLI worker panicked".into(),
                        })
                    })
                })
                .collect::<Vec<_>>()
        })
    });

    let mut merged = TypeCheckResult {
        exit_code: 0,
        success: true,
        diagnostics: Vec::new(),
    };
    for (index, result) in results.into_iter().enumerate() {
        let result = result?;
        merged.exit_code = merged.exit_code.max(result.exit_code);
        merged.success = merged.success && result.success;
        merged.diagnostics.extend(
            result
                .diagnostics
                .into_iter()
                .filter(|diagnostic| owners.get(&diagnostic.file).copied().unwrap_or(0) == index),
        );
    }
    Ok(merged)
}

/// Pick the shard count for a project when the caller did not request one.
/// Corsa's checker pool uses ~4 cores per process; sharding only pays off
/// once there are enough Vue files to amortize each extra program's fixed
/// parse/bind cost.
pub(super) fn auto_server_count(project: &VirtualProject) -> usize {
    let vue_files = project
        .virtual_files_sorted()
        .iter()
        .filter(|file| is_vue_original(&file.original_path))
        .count();
    let threads = std::thread::available_parallelism()
        .map(std::num::NonZero::get)
        .unwrap_or(1);
    (threads / 4).min(vue_files / 64).clamp(1, 8)
}

struct ShardPlan<'a> {
    /// Virtual paths to include per shard (owned Vue files plus every shared
    /// file).
    shards: Vec<Vec<&'a Path>>,
    /// Original path -> owning shard for partitioned Vue files; files absent
    /// from the map (shared sources, project-level anchors) belong to shard 0.
    owners: FxHashMap<PathBuf, usize>,
}

/// Partition the project's source files into shard programs along the
/// connected components of their import graph. Files in different components
/// never load each other, so component-aligned shards check disjoint code and
/// duplicate no work; interconnected projects collapse into one big component
/// and degrade to a single, unsharded run instead of paying N near-full
/// programs. Only ambient `.d.ts` files and sources carrying module/global
/// declarations stay visible to every shard, since they affect the whole
/// program without being imported.
fn partition_virtual_files(project: &VirtualProject, servers: usize) -> ShardPlan<'_> {
    let files = project.virtual_files_sorted();
    let mut partitioned: Vec<&super::super::VirtualFile> = Vec::new();
    let mut shared: Vec<&Path> = Vec::new();
    for file in files {
        // The program-wide check reads the original source: the generated Vue
        // wrapper carries no `declare global` of its own — the shared
        // ImportMeta augmentation lives once per program in the hoisted
        // helpers file (SHARED_HELPERS_FILE), which every shard includes.
        let program_wide = project
            .original_content_for_virtual(&file.virtual_path)
            .is_some_and(declares_program_wide_types);
        if program_wide || is_ambient_declaration(&file.original_path) {
            shared.push(file.virtual_path.as_path());
        } else {
            partitioned.push(file);
        }
    }

    let servers = servers.clamp(1, partitioned.len().max(1));
    let no_sharding = ShardPlan {
        shards: Vec::new(),
        owners: FxHashMap::default(),
    };
    if servers <= 1 {
        return no_sharding;
    }

    // Union files that load each other or the same unresolved modules. The
    // graph is a cost model, not a correctness requirement — ownership
    // filtering already deduplicates diagnostics — but files coupled through
    // shared sources would otherwise be re-checked by every shard whose
    // program loads them. Relative imports resolve exactly; project path
    // aliases (`@/…`) and workspace packages symlinked into `node_modules`
    // couple their importers, while bare npm specifiers are dependency cost
    // every program pays anyway.
    let index_by_virtual: FxHashMap<&Path, usize> = partitioned
        .iter()
        .enumerate()
        .map(|(index, file)| (file.virtual_path.as_path(), index))
        .collect();
    let alias_prefixes = project.path_alias_prefixes();
    let mut components = UnionFind::new(partitioned.len());
    let mut coupling_keys: FxHashMap<String, usize> = FxHashMap::default();
    let mut workspace_packages: FxHashMap<String, bool> = FxHashMap::default();
    for (index, file) in partitioned.iter().enumerate() {
        for specifier in import_specifiers(&file.content) {
            if specifier.starts_with("./") || specifier.starts_with("../") {
                let Some(base) = file.virtual_path.parent() else {
                    continue;
                };
                let target = normalize_join(base, specifier);
                if let Some(target_index) = resolve_virtual_import(&target, &index_by_virtual) {
                    components.union(index, target_index);
                } else {
                    // An unresolved local module: couple its importers.
                    let key = String::from(target.to_string_lossy());
                    match coupling_keys.get(key.as_str()) {
                        Some(&first) => components.union(index, first),
                        None => {
                            coupling_keys.insert(key, index);
                        }
                    }
                }
            } else if let Some(alias) = alias_prefixes
                .iter()
                .find(|alias| specifier.starts_with(alias.as_str()))
            {
                let key = cstr!("alias:{alias}");
                match coupling_keys.get(key.as_str()) {
                    Some(&first) => components.union(index, first),
                    None => {
                        coupling_keys.insert(key, index);
                    }
                }
            } else if let Some(package) =
                workspace_source_package(project.project_root(), specifier, &mut workspace_packages)
            {
                let key = cstr!("workspace:{package}");
                match coupling_keys.get(key.as_str()) {
                    Some(&first) => components.union(index, first),
                    None => {
                        coupling_keys.insert(key, index);
                    }
                }
            }
        }
    }

    // Bin-pack components (heaviest first) into the requested shard count and
    // only keep the plan when it buys real parallelism: a dominant component
    // means each extra program would mostly re-check the same files. Weights
    // are generated-content bytes, a usable proxy for parse+check cost.
    let mut component_files: FxHashMap<usize, Vec<usize>> = FxHashMap::default();
    for index in 0..partitioned.len() {
        component_files
            .entry(components.find(index))
            .or_default()
            .push(index);
    }
    let weight = |file_indices: &[usize]| -> usize {
        file_indices
            .iter()
            .map(|&index| partitioned[index].content.len())
            .sum()
    };
    let mut component_groups: Vec<Vec<usize>> = component_files.into_values().collect();
    if component_groups.len() < 2 {
        return no_sharding;
    }
    let total_weight: usize = component_groups.iter().map(|group| weight(group)).sum();
    component_groups.sort_by(|left, right| {
        weight(right)
            .cmp(&weight(left))
            .then_with(|| left.first().cmp(&right.first()))
    });

    let servers = servers.min(component_groups.len());
    let mut bins: Vec<(usize, Vec<usize>)> = vec![(0, Vec::new()); servers];
    for group in component_groups {
        let bin = bins
            .iter_mut()
            .min_by_key(|(bin_weight, _)| *bin_weight)
            .expect("at least one shard bin");
        bin.0 += weight(&group);
        bin.1.extend(group);
    }
    let largest = bins.iter().map(|(bin_weight, _)| *bin_weight).max();
    // Wall time tracks the heaviest shard; below ~25% savings the duplicated
    // per-program work on shared and ambient sources outweighs the win.
    if largest.unwrap_or(0) * 4 >= total_weight * 3 {
        return no_sharding;
    }

    let mut shards: Vec<Vec<&Path>> = Vec::with_capacity(bins.len());
    let mut owners = FxHashMap::default();
    for (shard_index, (_, file_indices)) in bins.into_iter().enumerate() {
        let mut include = shared.clone();
        for file_index in file_indices {
            let file = partitioned[file_index];
            include.push(file.virtual_path.as_path());
            owners.insert(file.original_path.clone(), shard_index);
        }
        shards.push(include);
    }

    ShardPlan { shards, owners }
}

/// Quoted module specifiers in generated virtual TS: `from '<spec>'`,
/// `import('<spec>')`, `import '<spec>'`, `require('<spec>')`. A lexical scan
/// is enough here — the result only feeds the shard cost model.
fn import_specifiers(content: &str) -> Vec<&str> {
    let mut specifiers = Vec::new();
    for token in ["from ", "import(", "import ", "require("] {
        for (at, _) in content.match_indices(token) {
            let rest = content[at + token.len()..].trim_start();
            let Some(quote) = rest.chars().next().filter(|ch| matches!(ch, '\'' | '"')) else {
                continue;
            };
            let rest = &rest[1..];
            let Some(end) = rest.find(quote) else {
                continue;
            };
            specifiers.push(&rest[..end]);
        }
    }
    specifiers
}

/// Whether a bare specifier resolves to a workspace package whose source is
/// symlinked into `node_modules` (pnpm/yarn workspaces): importing it drags
/// real project source into the program, so its importers are cost-coupled.
/// Regular npm packages resolve inside `node_modules` and are ambient cost
/// every program pays anyway. Results are cached per package root.
fn workspace_source_package<'spec>(
    project_root: &Path,
    specifier: &'spec str,
    cache: &mut FxHashMap<String, bool>,
) -> Option<&'spec str> {
    let mut segments = specifier.splitn(3, '/');
    let first = segments.next()?;
    let package_end = if first.starts_with('@') {
        first.len() + 1 + segments.next()?.len()
    } else {
        first.len()
    };
    let package = &specifier[..package_end];

    if let Some(&is_workspace) = cache.get(package) {
        return is_workspace.then_some(package);
    }

    let mut is_workspace = false;
    let mut dir = Some(project_root);
    while let Some(current) = dir {
        let candidate = current.join("node_modules").join(package);
        if let Ok(metadata) = std::fs::symlink_metadata(&candidate) {
            if metadata.file_type().is_symlink()
                && let Ok(target) = std::fs::canonicalize(&candidate)
            {
                is_workspace = !target
                    .components()
                    .any(|component| component.as_os_str() == "node_modules");
            }
            break;
        }
        dir = current.parent();
    }

    cache.insert(String::from(package), is_workspace);
    is_workspace.then_some(package)
}

fn normalize_join(base: &Path, specifier: &str) -> PathBuf {
    let mut normalized = base.to_path_buf();
    for component in Path::new(specifier).components() {
        match component {
            std::path::Component::CurDir => {}
            std::path::Component::ParentDir => {
                normalized.pop();
            }
            other => normalized.push(other.as_os_str()),
        }
    }
    normalized
}

struct UnionFind {
    parent: Vec<usize>,
}

impl UnionFind {
    fn new(size: usize) -> Self {
        Self {
            parent: (0..size).collect(),
        }
    }

    fn find(&mut self, node: usize) -> usize {
        let mut root = node;
        while self.parent[root] != root {
            root = self.parent[root];
        }
        let mut current = node;
        while self.parent[current] != root {
            let next = self.parent[current];
            self.parent[current] = root;
            current = next;
        }
        root
    }

    fn union(&mut self, left: usize, right: usize) {
        let left_root = self.find(left);
        let right_root = self.find(right);
        if left_root != right_root {
            self.parent[right_root] = left_root;
        }
    }
}

fn is_vue_original(path: &Path) -> bool {
    path.extension().is_some_and(|extension| extension == "vue")
}

fn is_ambient_declaration(path: &Path) -> bool {
    path.file_name()
        .and_then(|name| name.to_str())
        .is_some_and(|name| name.ends_with(".d.ts"))
}

fn declares_program_wide_types(content: &str) -> bool {
    content.contains("declare module") || content.contains("declare global")
}

/// Resolve a normalized relative import target against the registered virtual
/// files, trying the extension candidates TypeScript would.
fn resolve_virtual_import(
    target: &Path,
    index_by_virtual: &FxHashMap<&Path, usize>,
) -> Option<usize> {
    if let Some(&index) = index_by_virtual.get(target) {
        return Some(index);
    }
    let target_str = target.to_string_lossy();
    for suffix in [".ts", ".tsx", ".d.ts", "/index.ts", "/index.tsx"] {
        let candidate = PathBuf::from(cstr!("{target_str}{suffix}").as_str());
        if let Some(&index) = index_by_virtual.get(candidate.as_path()) {
            return Some(index);
        }
    }
    None
}

fn run_cli_for_config(
    corsa_path: &Path,
    project: &VirtualProject,
    config_path: &Path,
    checkers: Option<usize>,
) -> CorsaResult<TypeCheckResult> {
    let output = profile!("canon.corsa.cli.command", {
        let mut command = Command::new(corsa_path);
        command.current_dir(project.virtual_root());
        // Corsa's checker pool defaults to four workers; size it to the share
        // of the machine this program gets so wide machines are not idle.
        if let Some(checkers) = checkers {
            command.arg("--checkers").arg(cstr!("{checkers}").as_str());
        }
        command
            .arg("--pretty")
            .arg("false")
            .arg("--project")
            .arg(config_path)
            .output()
    })?;
    let diagnostics = profile!(
        "canon.corsa.cli.parse",
        parse_output_diagnostics(&output, project)
    );

    // An older runtime without `--checkers` support rejects the whole
    // invocation with TS5023; retry once without the option.
    if checkers.is_some()
        && !output.status.success()
        && diagnostics.iter().any(|diagnostic| {
            diagnostic.code == Some(5023) && diagnostic.message.contains("checkers")
        })
    {
        return run_cli_for_config(corsa_path, project, config_path, None);
    }

    let success = output.status.success()
        && diagnostics
            .iter()
            .all(|diagnostic| diagnostic.severity != 1);

    // A non-zero exit is a runner failure only when the output carries no
    // diagnostic-shaped lines at all (bad invocation, crash, missing CLI
    // support). Recognizable diagnostics whose every entry was suppressed or
    // failed source mapping still prove the CLI ran the project; falling back
    // to the per-file project-session API there costs orders of magnitude
    // more wall time for the same answer.
    if !output.status.success()
        && diagnostics.is_empty()
        && !output_contains_diagnostic_lines(&output)
    {
        return Err(CorsaError::CorsaExecution {
            exit_code: output.status.code().unwrap_or(-1),
            message: output_message(&output),
        });
    }

    Ok(TypeCheckResult {
        exit_code: output.status.code().unwrap_or(if success { 0 } else { 1 }),
        success,
        diagnostics,
    })
}

fn parse_output_diagnostics(output: &Output, project: &VirtualProject) -> Vec<Diagnostic> {
    let mut diagnostics = Vec::new();
    let mut mapper = DiagnosticMapper::new(project);
    #[allow(clippy::disallowed_types)]
    let stdout = std::string::String::from_utf8_lossy(&output.stdout);
    parse_cli_diagnostics(stdout.as_ref(), project, &mut mapper, &mut diagnostics);
    #[allow(clippy::disallowed_types)]
    let stderr = std::string::String::from_utf8_lossy(&output.stderr);
    parse_cli_diagnostics(stderr.as_ref(), project, &mut mapper, &mut diagnostics);
    diagnostics
}

fn parse_cli_diagnostics(
    output: &str,
    project: &VirtualProject,
    mapper: &mut DiagnosticMapper<'_>,
    diagnostics: &mut Vec<Diagnostic>,
) {
    for line in output.lines() {
        if let Some(diagnostic) = parse_cli_diagnostic_line(line, project, mapper) {
            diagnostics.push(diagnostic);
            continue;
        }
        // Project-level diagnostics carry no file position (`error TS2688:
        // Cannot find type definition file for 'x'.`). They are real,
        // user-actionable problems — tsc and vue-tsc report them and the
        // runtime may skip the semantic pass because of them — so they are
        // attributed to the project's tsconfig instead of being dropped.
        if let Some(diagnostic) = parse_global_diagnostic_line(line, project) {
            diagnostics.push(diagnostic);
            continue;
        }
        if is_cli_diagnostic_line(line) {
            continue;
        }

        let Some(last) = diagnostics.last_mut() else {
            continue;
        };
        let line = line.trim();
        if line.is_empty() {
            continue;
        }
        last.message.push('\n');
        last.message.push_str(line);
    }
}

/// Parse a file-less project-level diagnostic such as
/// `error TS2688: Cannot find type definition file for 'vite/client'.`
fn parse_global_diagnostic_line(line: &str, project: &VirtualProject) -> Option<Diagnostic> {
    let (severity, rest) = line.split_once(' ')?;
    let severity = match severity {
        "error" => 1,
        "warning" => 2,
        "info" => 3,
        _ => return None,
    };
    let (code, message) = rest.split_once(": ")?;
    let code = code.strip_prefix("TS")?.parse::<u32>().ok()?;
    if should_skip_diagnostic(Some(code), message) {
        return None;
    }

    Some(Diagnostic {
        file: project.project_diagnostics_anchor(),
        line: 0,
        column: 0,
        message: message.into(),
        code: Some(code),
        severity,
        block_type: None,
    })
}

fn parse_cli_diagnostic_line(
    line: &str,
    project: &VirtualProject,
    mapper: &mut DiagnosticMapper<'_>,
) -> Option<Diagnostic> {
    let (prefix, suffix) = line.split_once("): ")?;
    let open = prefix.rfind('(')?;
    let path = &prefix[..open];
    let position = &prefix[open + 1..];
    let (line, column) = position.split_once(',')?;
    let line = line.parse::<u32>().ok()?.saturating_sub(1);
    let column = column.parse::<u32>().ok()?.saturating_sub(1);

    let (severity, rest) = suffix.split_once(' ')?;
    let severity = match severity {
        "error" => 1,
        "warning" => 2,
        "info" => 3,
        _ => return None,
    };
    let (code, message) = rest.split_once(": ")?;
    let code = code
        .strip_prefix("TS")
        .and_then(|code| code.parse::<u32>().ok());
    if should_skip_diagnostic(code, message) {
        return None;
    }
    if code == Some(6133) && !mapper.preserves_unused_diagnostics() {
        return None;
    }

    let virtual_path = normalize_cli_path(path, project.virtual_root());
    let original = mapper.map_to_original(&virtual_path, line, column)?;
    if should_skip_original_diagnostic(code, &original) {
        return None;
    }

    // Suppress the false `TS2307` raised for a relative import of a sibling that
    // exists on disk but sits outside an explicit file subset. See the matching
    // check in `diagnostics::map_lsp_diagnostic`.
    if code == Some(2307) && relative_module_resolves_on_disk(message, &original.path) {
        return None;
    }

    Some(Diagnostic {
        file: original.path,
        line: original.line,
        column: original.column,
        message: message.into(),
        code,
        severity,
        block_type: original.block_type,
    })
}

fn output_contains_diagnostic_lines(output: &Output) -> bool {
    [&output.stdout, &output.stderr].into_iter().any(|stream| {
        #[allow(clippy::disallowed_types)]
        let text = std::string::String::from_utf8_lossy(stream);
        text.lines()
            .any(|line| is_cli_diagnostic_line(line) || is_global_diagnostic_line(line))
    })
}

/// Whether `line` is a file-less project-level diagnostic such as
/// `error TS2688: Cannot find type definition file for 'vite/client'.`
fn is_global_diagnostic_line(line: &str) -> bool {
    let Some(rest) = line
        .strip_prefix("error ")
        .or_else(|| line.strip_prefix("warning "))
        .or_else(|| line.strip_prefix("info "))
    else {
        return false;
    };
    let Some(code) = rest.strip_prefix("TS") else {
        return false;
    };
    let digits = code.bytes().take_while(u8::is_ascii_digit).count();
    digits > 0 && code[digits..].starts_with(':')
}

fn is_cli_diagnostic_line(line: &str) -> bool {
    let Some((prefix, suffix)) = line.split_once("): ") else {
        return false;
    };
    let Some(open) = prefix.rfind('(') else {
        return false;
    };
    let position = &prefix[open + 1..];
    let Some((line, column)) = position.split_once(',') else {
        return false;
    };
    if line.parse::<u32>().is_err() || column.parse::<u32>().is_err() {
        return false;
    }

    matches!(
        suffix.split_once(' ').map(|(severity, _)| severity),
        Some("error" | "warning" | "info")
    )
}

fn normalize_cli_path(path: &str, virtual_root: &Path) -> PathBuf {
    let path = PathBuf::from(path);
    if path.is_absolute() {
        path
    } else {
        virtual_root.join(path)
    }
}

fn output_message(output: &Output) -> String {
    #[allow(clippy::disallowed_types)]
    let stderr = std::string::String::from_utf8_lossy(&output.stderr);
    #[allow(clippy::disallowed_types)]
    let stdout = std::string::String::from_utf8_lossy(&output.stdout);
    let stderr = stderr.trim();
    let stdout = stdout.trim();
    if stderr.is_empty() {
        return stdout.to_owned().into();
    }
    if stdout.is_empty() {
        return stderr.to_owned().into();
    }
    cstr!("{}\n{}", stderr, stdout)
}

#[cfg(test)]
mod tests {
    use super::{is_cli_diagnostic_line, is_global_diagnostic_line, parse_cli_diagnostics};
    use crate::batch::VirtualProject;
    use crate::batch::executor::diagnostics::DiagnosticMapper;
    use std::{
        fs,
        path::PathBuf,
        sync::atomic::{AtomicUsize, Ordering},
    };
    use vize_carton::cstr;

    fn unique_case_dir(name: &str) -> PathBuf {
        static NEXT_CASE_ID: AtomicUsize = AtomicUsize::new(0);

        let case_id = NEXT_CASE_ID.fetch_add(1, Ordering::Relaxed);
        PathBuf::from(env!("CARGO_MANIFEST_DIR"))
            .join("target")
            .join("vize-tests")
            .join("tests")
            .join(&*cstr!(
                "cli-fallback-{name}-{}-{case_id}",
                std::process::id()
            ))
    }

    #[test]
    fn partitions_vue_files_and_shares_program_wide_sources() {
        use super::partition_virtual_files;

        let case_dir = unique_case_dir("shard-partition");
        let _ = fs::remove_dir_all(&case_dir);
        let src = case_dir.join("src");
        fs::create_dir_all(&src).unwrap();
        for index in 0..4 {
            fs::write(
                src.join(cstr!("Comp{index}.vue").as_str()),
                "<script setup lang=\"ts\">const n = 1</script><template>{{ n }}</template>",
            )
            .unwrap();
        }
        // A Vue file whose script augments the program must stay shared.
        fs::write(
            src.join("Augment.vue"),
            "<script lang=\"ts\">declare global { interface Window { __x?: number } }\nexport default {}</script>",
        )
        .unwrap();
        fs::write(src.join("util.ts"), "export const util = 1;\n").unwrap();

        let mut project = crate::batch::VirtualProject::new(&case_dir).unwrap();
        let paths: Vec<_> = fs::read_dir(&src)
            .unwrap()
            .map(|entry| entry.unwrap().path())
            .collect();
        project.register_paths(&paths).unwrap();

        let plan = partition_virtual_files(&project, 2);
        assert_eq!(plan.shards.len(), 2);
        // 4 Vue files and the unimported util.ts partition across both shards.
        assert_eq!(plan.owners.len(), 5);
        assert!(plan.owners.values().any(|&shard| shard == 0));
        assert!(plan.owners.values().any(|&shard| shard == 1));
        let util_owner = plan.owners.get(&src.join("util.ts")).copied();
        assert!(util_owner.is_some(), "plain sources are partitioned too");
        // The augmenting .vue is included in every shard.
        for shard in &plan.shards {
            assert!(
                shard
                    .iter()
                    .any(|path| path.to_string_lossy().ends_with("Augment.vue.ts")),
                "program-wide augmentations must be visible to every shard"
            );
        }

        let _ = fs::remove_dir_all(&case_dir);
    }

    #[test]
    fn shards_along_import_graph_components() {
        use super::partition_virtual_files;

        let case_dir = unique_case_dir("shard-components");
        let _ = fs::remove_dir_all(&case_dir);
        let src = case_dir.join("src");
        fs::create_dir_all(&src).unwrap();
        // A imports B (one component); C and D are isolated.
        fs::write(
            src.join("A.vue"),
            "<script setup lang=\"ts\">import B from './B.vue'\nvoid B</script><template><B /></template>",
        )
        .unwrap();
        for name in ["B", "C", "D"] {
            fs::write(
                src.join(cstr!("{name}.vue").as_str()),
                "<script setup lang=\"ts\">const n = 1</script><template>{{ n }}</template>",
            )
            .unwrap();
        }

        let mut project = crate::batch::VirtualProject::new(&case_dir).unwrap();
        let paths: Vec<_> = fs::read_dir(&src)
            .unwrap()
            .map(|entry| entry.unwrap().path())
            .collect();
        project.register_paths(&paths).unwrap();

        let plan = partition_virtual_files(&project, 2);
        assert_eq!(plan.shards.len(), 2);
        // Import-connected files must land in the same shard so no shard
        // re-checks another shard's Vue files through transitive loading.
        let owner_a = plan.owners.get(&src.join("A.vue")).copied();
        let owner_b = plan.owners.get(&src.join("B.vue")).copied();
        assert!(owner_a.is_some());
        assert_eq!(owner_a, owner_b);

        // A dominant connected component degrades to an unsharded run: link
        // C into the A/B component so only D stays separate (3 vs 1 files).
        fs::write(
            src.join("C.vue"),
            "<script setup lang=\"ts\">import A from './A.vue'\nvoid A</script><template><A /></template>",
        )
        .unwrap();
        let mut project = crate::batch::VirtualProject::new(&case_dir).unwrap();
        let paths: Vec<_> = fs::read_dir(&src)
            .unwrap()
            .map(|entry| entry.unwrap().path())
            .collect();
        project.register_paths(&paths).unwrap();
        let plan = partition_virtual_files(&project, 2);
        assert!(
            plan.shards.is_empty(),
            "a dominant component must collapse to a single program"
        );

        let _ = fs::remove_dir_all(&case_dir);
    }

    #[test]
    fn recognizes_global_and_positioned_diagnostic_lines() {
        assert!(is_global_diagnostic_line(
            "error TS2688: Cannot find type definition file for 'vite/client'."
        ));
        assert!(is_global_diagnostic_line("warning TS1: w"));
        assert!(!is_global_diagnostic_line(
            "  The file is in the program because:"
        ));
        assert!(!is_global_diagnostic_line("error: missing argument"));
        assert!(!is_global_diagnostic_line("error TSX: nope"));

        assert!(is_cli_diagnostic_line(
            "src/App.vue.ts(3,7): error TS2322: Type 'string' is not assignable to type 'number'."
        ));
        assert!(!is_cli_diagnostic_line(
            "error TS2688: Cannot find type definition file for 'vite/client'."
        ));
    }

    #[test]
    fn parses_cli_diagnostics_back_to_original_files() {
        let case_dir = unique_case_dir("diagnostics");
        let _ = fs::remove_dir_all(&case_dir);
        let source = case_dir.join("src").join("main.ts");
        fs::create_dir_all(source.parent().unwrap()).unwrap();
        fs::write(&source, "const value: number = 'x';\n").unwrap();

        let mut project = VirtualProject::new(&case_dir).unwrap();
        project.register_path(&source).unwrap();
        project.materialize().unwrap();

        let output = cstr!(
            "{}(1,7): error TS2322: Type 'string' is not assignable to type 'number'.",
            project.virtual_root().join("src").join("main.ts").display()
        );
        let mut diagnostics = Vec::new();
        let mut mapper = DiagnosticMapper::new(&project);
        parse_cli_diagnostics(output.as_str(), &project, &mut mapper, &mut diagnostics);

        assert_eq!(diagnostics.len(), 1);
        assert_eq!(diagnostics[0].file, source);
        assert_eq!(diagnostics[0].line, 0);
        assert_eq!(diagnostics[0].column, 6);
        assert_eq!(diagnostics[0].code, Some(2322));

        let _ = fs::remove_dir_all(&case_dir);
    }
}