Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ pub(crate) fn collect_file_facts_from_program(
super::super::file_playwright::collect_playwright_facts(path, program, source, playwright);
let stored_source = should_store_source(plan).then(|| std::sync::Arc::<str>::from(source));
let ts = TsFileFacts {
operational_error: None,
source: stored_source.as_deref().map(std::sync::Arc::<str>::from),
parse_error: None,
fatal_parse_error: false,
Expand Down
124 changes: 41 additions & 83 deletions crates/no-mistakes/src/codebase/queries/resolve_check.rs
Original file line number Diff line number Diff line change
@@ -1,36 +1,32 @@
use super::render::{render, resolve_format, to_json, Report};
use super::shared::resolve_target;
use crate::cli::Format;
use crate::codebase::dependencies::extract::{
is_tsx_file, ExtractedImport, ImportExtractor, ImportKind,
};
use crate::codebase::ts_resolver::ImportResolver;
use anyhow::{Context, Result};
use crate::codebase::dependencies::extract::ImportKind;
use anyhow::Result;
use is_terminal::IsTerminal;
use serde::Serialize;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::ExitCode;

/// `resolve-check`: do all imports in a single file resolve?
mod batch;
pub use batch::BatchResolveCheckReport;
use batch::{batch_report, compute_many};

/// `resolve-check`: do all imports in one or more files resolve?
#[derive(clap::Parser, Debug)]
pub struct ResolveCheckArgs {
/// The TS/JS file to check (relative to --root or absolute).
#[arg(value_name = "FILE")]
pub file: PathBuf,

/// TS/JS files to check (relative to --root or absolute).
#[arg(value_name = "FILE", required = true, num_args = 1..)]
pub files: Vec<PathBuf>,
/// Project root (default: current working directory).
#[arg(long, value_name = "PATH")]
pub root: Option<PathBuf>,

/// Path to tsconfig.json for alias resolution. If omitted, searches upward.
#[arg(long, value_name = "FILE")]
pub tsconfig: Option<PathBuf>,

/// Output format: json, yml, md, paths, human.
#[arg(long, value_name = "FORMAT")]
pub format: Option<Format>,

/// Shorthand for `--format json`.
#[arg(long, default_value_t = false)]
pub json: bool,
Expand All @@ -39,15 +35,12 @@ pub struct ResolveCheckArgs {
#[derive(Serialize, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
enum Status {
/// Resolved to a local file on disk.
Resolved,
/// A relative or aliased import whose target is missing — a real error.
Unresolved,
/// A bare npm package, Node builtin, or subpath import (not an error).
External,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
struct ImportRow {
specifier: String,
Expand All @@ -57,7 +50,7 @@ struct ImportRow {
resolved: Option<String>,
}

#[derive(Serialize)]
#[derive(Serialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct ResolveCheckReport {
file: String,
Expand Down Expand Up @@ -87,72 +80,20 @@ fn kind_str(kind: ImportKind) -> &'static str {
}
}

/// True for a `.d.ts`/`.d.mts`/`.d.cts` declaration file, which only satisfies
/// type-only references (no emitted runtime module).
/// Declaration files only satisfy type-only references because they do not
/// emit a runtime module.
fn is_declaration_file(path: &Path) -> bool {
let name = path.to_string_lossy();
name.ends_with(".d.ts") || name.ends_with(".d.mts") || name.ends_with(".d.cts")
}

/// Classify one import specifier against a shared resolver and tsconfig aliases.
/// The resolver handles NodeNext `.js`→source and declaration resolution; a
/// declaration file has no runtime module, so a value import of one is rejected.
fn classify(
imp: &ExtractedImport,
abs_file: &Path,
target: &super::shared::Target,
resolver: &ImportResolver,
) -> ImportRow {
let resolved = resolver
.resolve(&imp.specifier, abs_file)
.filter(|path| imp.kind == ImportKind::Type || !is_declaration_file(path));
let status = if resolved.is_some() {
Status::Resolved
} else if imp.specifier.starts_with('.') || resolver.matches_alias(&imp.specifier) {
Status::Unresolved
} else {
Status::External
};
ImportRow {
specifier: imp.specifier.clone(),
kind: kind_str(imp.kind),
status,
resolved: resolved.map(|abs| super::shared::rel_str(&abs, &target.root)),
}
}

fn compute(args: &ResolveCheckArgs) -> Result<ResolveCheckReport> {
let target = resolve_target(&args.file, args.root.as_deref(), args.tsconfig.as_deref())?;
let source = target
.sources
.read_path(&target.abs_file)
.context(format!("reading {}", target.abs_file.display()))?;
let extractor = if is_tsx_file(&target.abs_file) {
ImportExtractor::for_tsx()?
} else {
ImportExtractor::for_typescript()?
};
let resolver = ImportResolver::new(target.tsconfig()?)
.with_visible(target.visible_files())
.without_cache();
let imports: Vec<ImportRow> = extractor
.extract(&source)?
.iter()
.map(|imp| classify(imp, &target.abs_file, &target, &resolver))
.collect();

let unresolved: Vec<String> = imports
.iter()
.filter(|row| row.status == Status::Unresolved)
.map(|row| row.specifier.clone())
.collect();

Ok(ResolveCheckReport {
file: super::shared::rel_str(&target.abs_file, &target.root),
all_resolve: unresolved.is_empty(),
imports,
unresolved,
})
let mut reports = compute_many(args)?;
anyhow::ensure!(
reports.len() == 1,
"single-file report requested for multiple files"
);
Ok(reports.remove(0))
}

impl Report for ResolveCheckReport {
Expand Down Expand Up @@ -181,16 +122,33 @@ impl Report for ResolveCheckReport {
}

pub fn run(args: ResolveCheckArgs) -> Result<ExitCode> {
let report = compute(&args)?;
let format = resolve_format(args.json, args.format, io::stdout().is_terminal());
let stdout = io::stdout();
let mut out = stdout.lock();
render(&report, format, &mut out)?;
Ok(report.exit_code())
if args.files.len() == 1 {
let report = compute(&args)?;
render(&report, format, &mut out)?;
Ok(report.exit_code())
} else {
let report = batch_report(compute_many(&args)?);
render(&report, format, &mut out)?;
Ok(report.exit_code())
}
}

pub fn run_json(args: ResolveCheckArgs) -> Result<String> {
to_json(&compute(&args)?)
if args.files.len() == 1 {
to_json(&compute(&args)?)
} else {
to_json(&batch_report(compute_many(&args)?))
}
}

/// N-API's `files` option always requests the batch schema, including for a
/// one-element list. The CLI has no such wrapper distinction: one positional
/// file retains its original response shape.
pub fn run_json_batch(args: ResolveCheckArgs) -> Result<String> {
to_json(&batch_report(compute_many(&args)?))
}

#[cfg(test)]
Expand Down
199 changes: 199 additions & 0 deletions crates/no-mistakes/src/codebase/queries/resolve_check/batch.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
use super::{
is_declaration_file, kind_str, ImportRow, ResolveCheckArgs, ResolveCheckReport, Status,
};
use crate::codebase::dependencies::extract::{is_indexable, ExtractedImport, ImportKind};
use crate::codebase::queries::render::Report;
use crate::codebase::ts_resolver::ImportResolver;
use anyhow::Result;
use rayon::prelude::*;
use serde::Serialize;
use std::collections::BTreeSet;
use std::io::{self, Write};
use std::process::ExitCode;

/// The additive batch response. A one-file request retains the historical
/// `ResolveCheckReport` shape so existing CLI and Node consumers do not need
/// to special-case their single-file calls.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BatchResolveCheckReport {
all_resolve: bool,
unresolved_files: Vec<String>,
results: Vec<ResolveCheckReport>,
}

fn classify(
imp: &ExtractedImport,
target: &super::super::shared::Target,
resolver: &ImportResolver,
) -> ImportRow {
let resolved = resolver
.resolve(&imp.specifier, &target.abs_file)
.filter(|path| imp.kind == ImportKind::Type || !is_declaration_file(path));
let status = if resolved.is_some() {
Status::Resolved
} else if imp.specifier.starts_with('.') || resolver.matches_alias(&imp.specifier) {
Status::Unresolved
} else {
Status::External
};
ImportRow {
specifier: imp.specifier.clone(),
kind: kind_str(imp.kind),
status,
resolved: resolved.map(|abs| super::super::shared::rel_str(&abs, &target.root)),
}
}

fn compute_target(
target: &super::super::shared::Target,
imports: &[ExtractedImport],
) -> Result<ResolveCheckReport> {
let resolver = ImportResolver::new_in_session(
target.tsconfig()?,
Some(target.visible_files()),
&target.session,
);
let imports: Vec<ImportRow> = imports
.iter()
.map(|imp| classify(imp, target, &resolver))
.collect();
let unresolved: Vec<String> = imports
.iter()
.filter(|row| row.status == Status::Unresolved)
.map(|row| row.specifier.clone())
.collect();
Ok(ResolveCheckReport {
file: super::super::shared::rel_str(&target.abs_file, &target.root),
all_resolve: unresolved.is_empty(),
imports,
unresolved,
})
}

fn target_imports<'a>(
target: &super::super::shared::Target,
facts: &'a crate::codebase::ts_source::facts::TsFactMap,
) -> Result<&'a [ExtractedImport]> {
let facts = facts
.get(&target.abs_file)
.ok_or_else(|| anyhow::anyhow!("missing facts for {}", target.abs_file.display()))?;
if let Some(error) = &facts.operational_error {
anyhow::bail!("{error}");
}
if facts.fatal_parse_error {
anyhow::bail!(
"failed to parse {}: {}",
target.abs_file.display(),
facts
.parse_error
.as_deref()
.unwrap_or("parser panicked without a diagnostic")
);
}
Ok(&facts.imports)
}

/// Collect the union import demand once, then classify independent inputs in
/// parallel against their nearest (or explicit) tsconfig. The source facts are
/// recovered parser facts, retaining imports from syntactically malformed
/// files exactly like the project-wide analysis pipeline.
pub(super) fn compute_many(args: &ResolveCheckArgs) -> Result<Vec<ResolveCheckReport>> {
let targets = super::super::shared::resolve_targets(
&args.files,
args.root.as_deref(),
args.tsconfig.as_deref(),
)?;
// `resolve_targets` rejects an empty request before preparing its session.
// Indexing here keeps that boundary invariant explicit and avoids a second,
// unreachable empty-batch branch in the analysis layer.
targets[0].validate_explicit_tsconfig()?;
for target in &targets {
anyhow::ensure!(
is_indexable(&target.abs_file),
"unsupported JavaScript/TypeScript file: {}",
target.abs_file.display()
);
}
let facts = super::super::reverse::collect_target_import_facts(&targets[0], &targets);
let mut reports = targets
.par_iter()
.map(|target| compute_target(target, target_imports(target, &facts)?))
.collect::<Result<Vec<_>>>()?;
reports.sort_by(|left, right| left.file.cmp(&right.file));
Ok(reports)
}

pub(super) fn batch_report(results: Vec<ResolveCheckReport>) -> BatchResolveCheckReport {
let unresolved_files: Vec<String> = results
.iter()
.filter(|result| !result.all_resolve)
.map(|result| result.file.clone())
.collect();
BatchResolveCheckReport {
all_resolve: unresolved_files.is_empty(),
unresolved_files,
results,
}
}

impl BatchResolveCheckReport {
pub(super) fn exit_code(&self) -> ExitCode {
if self.all_resolve {
ExitCode::SUCCESS
} else {
ExitCode::FAILURE
}
}
}

impl Report for BatchResolveCheckReport {
fn write_human(&self, w: &mut dyn Write) -> io::Result<()> {
for (index, result) in self.results.iter().enumerate() {
if index > 0 {
writeln!(w)?;
}
result.write_human(w)?;
}
Ok(())
}

fn write_md(&self, w: &mut dyn Write) -> io::Result<()> {
for (index, result) in self.results.iter().enumerate() {
if index > 0 {
writeln!(w)?;
}
writeln!(w, "## {}", result.file)?;
for row in &result.imports {
match (row.status, &row.resolved) {
(Status::Resolved, Some(target)) => {
writeln!(w, "- ok: `{}` → `{target}`", row.specifier)?;
}
(Status::Unresolved, _) => writeln!(w, "- **MISSING:** `{}`", row.specifier)?,
_ => writeln!(w, "- external: `{}`", row.specifier)?,
}
}
}
Ok(())
}

fn write_paths(&self, w: &mut dyn Write) -> io::Result<()> {
let paths: BTreeSet<&str> = self
.results
.iter()
.flat_map(|result| {
result
.imports
.iter()
.filter_map(|row| row.resolved.as_deref())
})
.collect();
for path in paths {
writeln!(w, "{path}")?;
}
Ok(())
}
}

#[cfg(test)]
mod tests;
Loading
Loading