From 3f5c22be76edce0e8ce738ae83b1ab2b4d782320 Mon Sep 17 00:00:00 2001 From: Long Ho Date: Mon, 18 May 2026 10:28:20 -0400 Subject: [PATCH] feat: allow explicit import aliases --- codescythe.schema.json | 7 +++ crates/codescythe/analyze.rs | 88 ++++++++++++++++++++++++++++++++++-- crates/codescythe/config.rs | 22 ++++++++- 3 files changed, 113 insertions(+), 4 deletions(-) diff --git a/codescythe.schema.json b/codescythe.schema.json index e89d77a..1a6b0ba 100644 --- a/codescythe.schema.json +++ b/codescythe.schema.json @@ -21,6 +21,13 @@ "description": "Glob patterns to exclude from analysis.", "$ref": "#/$defs/stringOrStringArray" }, + "aliases": { + "description": "Import specifier aliases. These override package.json imports and may use wildcard keys such as #generated/*.", + "type": "object", + "additionalProperties": { + "$ref": "#/$defs/stringOrStringArray" + } + }, "includeEntryExports": { "type": "boolean", "default": false diff --git a/crates/codescythe/analyze.rs b/crates/codescythe/analyze.rs index 9891e0b..21bc2c1 100644 --- a/crates/codescythe/analyze.rs +++ b/crates/codescythe/analyze.rs @@ -11,7 +11,7 @@ use oxc::ast_visit::{Visit, walk}; use oxc_allocator::Allocator; use oxc_ast::ast::*; use oxc_parser::{Parser, ParserReturn}; -use oxc_resolver::{ResolveError, ResolveOptions, Resolver, TsconfigDiscovery}; +use oxc_resolver::{AliasValue, ResolveError, ResolveOptions, Resolver, TsconfigDiscovery}; use oxc_span::{GetSpan, SourceType, Span}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; @@ -101,7 +101,7 @@ pub fn analyze_path( .enumerate() .map(|(index, file)| (file.path.clone(), index)) .collect::>(); - let module_resolver = ModuleResolver::new(&cwd, &files); + let module_resolver = ModuleResolver::new(&cwd, &files, config); let mut entry_indexes = HashSet::::new(); let mut used_files = UsedFiles::new(); @@ -517,10 +517,11 @@ enum ImportResolution { } impl ModuleResolver { - fn new(cwd: &Path, files: &[FileData]) -> Self { + fn new(cwd: &Path, files: &[FileData], config: &CodescytheConfig) -> Self { let resolver = Resolver::new(ResolveOptions { cwd: Some(cwd.to_path_buf()), tsconfig: Some(TsconfigDiscovery::Auto), + alias: config_aliases(cwd, config), condition_names: vec!["node".into(), "import".into()], extensions: vec![ ".ts".into(), @@ -590,6 +591,35 @@ impl ModuleResolver { } } +fn config_aliases(cwd: &Path, config: &CodescytheConfig) -> Vec<(String, Vec)> { + config + .aliases + .iter() + .map(|(key, values)| { + ( + key.clone(), + values + .iter() + .map(|value| AliasValue::Path(config_alias_value(cwd, value))) + .collect(), + ) + }) + .collect() +} + +fn config_alias_value(cwd: &Path, value: &str) -> String { + if is_relative_alias_path(value) { + return normalize_path(&cwd.join(value)) + .to_string_lossy() + .replace('\\', "/"); + } + value.to_string() +} + +fn is_relative_alias_path(value: &str) -> bool { + value == "." || value == ".." || value.starts_with("./") || value.starts_with("../") +} + fn is_resolution_miss(error: &ResolveError) -> bool { matches!( error, @@ -1358,6 +1388,58 @@ mod tests { ); } + #[test] + fn explicit_aliases_override_package_json_imports() { + let tempdir = tempfile::tempdir().unwrap(); + let cwd = tempdir.path(); + + write_file( + cwd, + "codescythe.json", + r##"{ + "entry": "src/main.ts", + "project": [ + "src/**/*.ts", + "generated/**/*.ts", + "wrong/**/*.ts" + ], + "aliases": { + "#generated/*": "./generated/*.ts" + } + }"##, + ); + write_file( + cwd, + "package.json", + r##"{ + "imports": { + "#generated/*": "./wrong/*.ts" + } + }"##, + ); + write_file( + cwd, + "src/main.ts", + "import { used } from '#generated/used';\nconsole.log(used);\n", + ); + write_file(cwd, "generated/used.ts", "export const used = 1;\n"); + write_file(cwd, "wrong/used.ts", "export const used = 1;\n"); + + let config = crate::load_config(cwd, None).unwrap(); + let analysis = analyze_path(cwd, &config, AnalysisOptions::default()).unwrap(); + + assert!(analysis.issues.unresolved.is_empty()); + assert!(!analysis.issues.files.contains_key("generated/used.ts")); + assert!(analysis.issues.files.contains_key("wrong/used.ts")); + assert!( + !analysis + .issues + .exports + .get("generated/used.ts") + .is_some_and(|exports| exports.contains_key("used")) + ); + } + #[test] fn reports_missing_local_imports() { let tempdir = tempfile::tempdir().unwrap(); diff --git a/crates/codescythe/config.rs b/crates/codescythe/config.rs index 7f3e94e..29f1837 100644 --- a/crates/codescythe/config.rs +++ b/crates/codescythe/config.rs @@ -1,4 +1,4 @@ -use std::{fs, path::Path}; +use std::{collections::BTreeMap, fs, path::Path}; use anyhow::{Context, Result, bail}; use serde::{Deserialize, Serialize}; @@ -15,6 +15,8 @@ pub struct CodescytheConfig { pub project: Vec, #[serde(deserialize_with = "deserialize_patterns")] pub ignore: Vec, + #[serde(deserialize_with = "deserialize_aliases")] + pub aliases: BTreeMap>, pub include_entry_exports: bool, pub ignore_exports_used_in_file: bool, } @@ -109,6 +111,24 @@ where }) } +fn deserialize_aliases<'de, D>(deserializer: D) -> Result>, D::Error> +where + D: serde::Deserializer<'de>, +{ + let value = Option::>::deserialize(deserializer)?; + Ok(value + .unwrap_or_default() + .into_iter() + .map(|(key, value)| { + let values = match value { + StringOrVec::String(value) => vec![value], + StringOrVec::Vec(values) => values, + }; + (key, values) + }) + .collect()) +} + #[derive(Deserialize)] #[serde(untagged)] enum StringOrVec {