diff --git a/codescythe.schema.json b/codescythe.schema.json index e89d77a..6f91d7a 100644 --- a/codescythe.schema.json +++ b/codescythe.schema.json @@ -21,6 +21,23 @@ "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" + } + }, + "ignoreUnresolved": { + "description": "Unresolved import specifier glob patterns to ignore.", + "$ref": "#/$defs/stringOrStringArray" + }, + "unresolvedImports": { + "description": "Controls how unresolved imports are handled.", + "type": "string", + "enum": ["report", "ignore", "error"], + "default": "report" + }, "includeEntryExports": { "type": "boolean", "default": false diff --git a/crates/codescythe/analyze.rs b/crates/codescythe/analyze.rs index 16d4fec..4538651 100644 --- a/crates/codescythe/analyze.rs +++ b/crates/codescythe/analyze.rs @@ -11,13 +11,13 @@ 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}; use walkdir::{DirEntry, WalkDir}; -use crate::CodescytheConfig; +use crate::{CodescytheConfig, UnresolvedImportsMode}; const PARSE_THREADS_ENV: &str = "CODESCYTHE_PARSE_THREADS"; const RAYON_THREADS_ENV: &str = "RAYON_NUM_THREADS"; @@ -86,7 +86,7 @@ pub fn analyze_path( config: &CodescytheConfig, options: AnalysisOptions, ) -> Result { - let cwd = normalize_path(cwd); + let cwd = absolute_normalize_path(cwd)?; if !cwd.exists() { anyhow::bail!("analysis root does not exist: {}", cwd.display()); } @@ -101,7 +101,8 @@ 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 unresolved_policy = UnresolvedImportPolicy::new(config)?; let mut entry_indexes = HashSet::::new(); let mut used_files = UsedFiles::new(); @@ -133,10 +134,7 @@ pub fn analyze_path( } } ImportResolution::Unresolved => { - unresolved - .entry(file.relative.clone()) - .or_default() - .insert(import.source.clone()); + unresolved_policy.record(&mut unresolved, &file.relative, &import.source)?; } ImportResolution::External => {} } @@ -150,10 +148,7 @@ pub fn analyze_path( } } ImportResolution::Unresolved => { - unresolved - .entry(file.relative.clone()) - .or_default() - .insert(source.clone()); + unresolved_policy.record(&mut unresolved, &file.relative, source)?; } ImportResolution::External => {} } @@ -171,6 +166,7 @@ pub fn analyze_path( &mut used_exports, &mut queue, &mut unresolved, + &unresolved_policy, &file.relative, )?; } @@ -191,6 +187,7 @@ pub fn analyze_path( &mut used_exports, &mut queue, &mut unresolved, + &unresolved_policy, &file.relative, )?; } @@ -209,6 +206,7 @@ pub fn analyze_path( &mut used_exports, &mut queue, &mut unresolved, + &unresolved_policy, )?; } for source in &file.reexport_all { @@ -221,6 +219,7 @@ pub fn analyze_path( &mut used_exports, &mut queue, &mut unresolved, + &unresolved_policy, )?; } } @@ -236,6 +235,7 @@ pub fn analyze_path( &mut used_exports, &mut queue, &mut unresolved, + &unresolved_policy, )?; } } @@ -321,7 +321,7 @@ fn discover_project_files(cwd: &Path, config: &CodescytheConfig) -> Result Result { } fn should_enter(entry: &DirEntry) -> bool { - if !entry.file_type().is_dir() { + if !entry.path().is_dir() { return true; } !matches!( @@ -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,74 @@ impl ModuleResolver { } } +struct UnresolvedImportPolicy { + mode: UnresolvedImportsMode, + ignore: GlobSet, +} + +impl UnresolvedImportPolicy { + fn new(config: &CodescytheConfig) -> Result { + Ok(Self { + mode: config.unresolved_imports, + ignore: build_glob_set(&config.ignore_unresolved)?, + }) + } + + fn record( + &self, + unresolved: &mut UnresolvedImports, + importer: &str, + specifier: &str, + ) -> Result<()> { + if self.ignore.is_match(specifier) || self.mode == UnresolvedImportsMode::Ignore { + return Ok(()); + } + + match self.mode { + UnresolvedImportsMode::Report => { + unresolved + .entry(importer.to_string()) + .or_default() + .insert(specifier.to_string()); + Ok(()) + } + UnresolvedImportsMode::Ignore => Ok(()), + UnresolvedImportsMode::Error => { + anyhow::bail!("unresolved import {specifier:?} from {importer}") + } + } + } +} + +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, @@ -624,6 +693,7 @@ fn mark_member_import( used_exports: &mut UsedExports, queue: &mut VecDeque, unresolved: &mut UnresolvedImports, + unresolved_policy: &UnresolvedImportPolicy, importer_relative: &str, ) -> Result<()> { match resolver.resolve(from_file, source)? { @@ -647,16 +717,14 @@ fn mark_member_import( used_exports, queue, unresolved, + unresolved_policy, importer_relative, )?; } } } ImportResolution::Unresolved => { - unresolved - .entry(importer_relative.to_string()) - .or_default() - .insert(source.to_string()); + unresolved_policy.record(unresolved, importer_relative, source)?; } ImportResolution::External => {} } @@ -671,6 +739,7 @@ fn mark_reexport( used_exports: &mut UsedExports, queue: &mut VecDeque, unresolved: &mut UnresolvedImports, + unresolved_policy: &UnresolvedImportPolicy, ) -> Result<()> { if let (Some(source), Some(name)) = (&export.reexport_source, &export.reexport_name) { match resolver.resolve(file, source)? { @@ -681,10 +750,7 @@ fn mark_reexport( used_exports.entry(target).or_default().insert(name.clone()); } ImportResolution::Unresolved => { - unresolved - .entry(file.relative.clone()) - .or_default() - .insert(source.clone()); + unresolved_policy.record(unresolved, &file.relative, source)?; } ImportResolution::External => {} } @@ -698,10 +764,7 @@ fn mark_reexport( } } ImportResolution::Unresolved => { - unresolved - .entry(file.relative.clone()) - .or_default() - .insert(source.clone()); + unresolved_policy.record(unresolved, &file.relative, source)?; } ImportResolution::External => {} } @@ -718,6 +781,7 @@ fn mark_all_exports( used_exports: &mut UsedExports, queue: &mut VecDeque, unresolved: &mut UnresolvedImports, + unresolved_policy: &UnresolvedImportPolicy, ) -> Result<()> { match resolver.resolve(file, source)? { ImportResolution::Project(target) => { @@ -729,10 +793,7 @@ fn mark_all_exports( } } ImportResolution::Unresolved => { - unresolved - .entry(file.relative.clone()) - .or_default() - .insert(source.to_string()); + unresolved_policy.record(unresolved, &file.relative, source)?; } ImportResolution::External => {} } @@ -1216,6 +1277,15 @@ fn relative_path(cwd: &Path, path: &Path) -> String { .replace('\\', "/") } +fn absolute_normalize_path(path: &Path) -> Result { + let path = if path.is_absolute() { + path.to_path_buf() + } else { + env::current_dir()?.join(path) + }; + Ok(normalize_path(&path)) +} + fn normalize_path(path: &Path) -> PathBuf { let mut normalized = PathBuf::new(); for component in path.components() { @@ -1313,6 +1383,152 @@ mod tests { assert!(analysis.issues.exports["app/extension.ts"].contains_key("unusedExtension")); } + #[test] + fn reads_package_json_imports_by_default() { + let tempdir = tempfile::tempdir().unwrap(); + let cwd = tempdir.path(); + + write_file( + cwd, + "codescythe.json", + r#"{ + "entry": "src/main.ts", + "project": "src/**/*.ts" + }"#, + ); + write_file( + cwd, + "package.json", + r##"{ + "imports": { + "#app/*": "./src/*.ts" + } + }"##, + ); + write_file( + cwd, + "src/main.ts", + "import { used } from '#app/used';\nconsole.log(used);\n", + ); + write_file(cwd, "src/used.ts", "export const used = 1;\n"); + write_file(cwd, "src/unused.ts", "export const unused = 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("src/used.ts")); + assert!(analysis.issues.files.contains_key("src/unused.ts")); + assert!( + !analysis + .issues + .exports + .get("src/used.ts") + .is_some_and(|exports| exports.contains_key("used")) + ); + } + + #[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 unresolved_import_modes_control_behavior() { + let report = analyze_missing_import(None).unwrap(); + assert_eq!( + report.issues.unresolved["src/main.ts"], + vec!["./missing".to_string()] + ); + assert_eq!(report.counters.unresolved, 1); + + let ignore = analyze_missing_import(Some("ignore")).unwrap(); + assert!(ignore.issues.unresolved.is_empty()); + assert_eq!(ignore.counters.unresolved, 0); + + let error = analyze_missing_import(Some("error")).unwrap_err(); + let message = format!("{error:#}"); + assert!(message.contains("src/main.ts")); + assert!(message.contains("./missing")); + } + + #[test] + fn ignored_unresolved_patterns_do_not_count_as_issues() { + let tempdir = tempfile::tempdir().unwrap(); + let cwd = tempdir.path(); + + write_file( + cwd, + "codescythe.json", + r##"{ + "entry": "src/main.ts", + "project": "src/**/*.ts", + "ignoreUnresolved": ["#virtual_generated/**"] + }"##, + ); + write_file( + cwd, + "src/main.ts", + "import '#virtual_generated/api/foo';\nimport './missing';\n", + ); + + let config = crate::load_config(cwd, None).unwrap(); + let analysis = analyze_path(cwd, &config, AnalysisOptions::default()).unwrap(); + + assert_eq!( + analysis.issues.unresolved["src/main.ts"], + vec!["./missing".to_string()] + ); + assert_eq!(analysis.counters.unresolved, 1); + } + #[test] fn reports_missing_local_imports() { let tempdir = tempfile::tempdir().unwrap(); @@ -1347,6 +1563,37 @@ console.log(missingExternal, missingExternalSubpath); ); } + fn analyze_missing_import(mode: Option<&str>) -> Result { + let tempdir = tempfile::tempdir().unwrap(); + let cwd = tempdir.path(); + let mode_config = mode + .map(|mode| format!(r#", "unresolvedImports": "{mode}""#)) + .unwrap_or_default(); + + write_file( + cwd, + "codescythe.json", + &format!( + r#"{{ + "entry": "src/main.ts", + "project": "src/**/*.ts"{mode_config} + }}"# + ), + ); + write_file(cwd, "src/main.ts", "import './missing';\n"); + + let config = crate::load_config(cwd, None).unwrap(); + analyze_path(cwd, &config, AnalysisOptions::default()) + } + + fn write_file(root: &Path, relative: &str, contents: &str) { + let path = root.join(relative); + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, contents).unwrap(); + } + fn fixture_path(name: &str) -> (tempfile::TempDir, PathBuf) { let relative = Path::new("tests/fixtures").join(name); let mut candidates = vec![ diff --git a/crates/codescythe/config.rs b/crates/codescythe/config.rs index 7f3e94e..ee00e12 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,10 +15,24 @@ pub struct CodescytheConfig { pub project: Vec, #[serde(deserialize_with = "deserialize_patterns")] pub ignore: Vec, + #[serde(deserialize_with = "deserialize_aliases")] + pub aliases: BTreeMap>, + #[serde(deserialize_with = "deserialize_patterns")] + pub ignore_unresolved: Vec, + pub unresolved_imports: UnresolvedImportsMode, pub include_entry_exports: bool, pub ignore_exports_used_in_file: bool, } +#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum UnresolvedImportsMode { + #[default] + Report, + Ignore, + Error, +} + pub fn load_config(cwd: &Path, config_path: Option<&Path>) -> Result { let value = match config_path { Some(path) => Some(read_config_file(path)?), @@ -109,6 +123,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 { diff --git a/crates/codescythe/lib.rs b/crates/codescythe/lib.rs index f505682..4a5cdb0 100644 --- a/crates/codescythe/lib.rs +++ b/crates/codescythe/lib.rs @@ -5,7 +5,7 @@ mod fix; pub use analyze::{ Analysis, AnalysisOptions, Counters, FileIssue, Issues, SymbolIssue, analyze_path, }; -pub use config::{CodescytheConfig, load_config}; +pub use config::{CodescytheConfig, UnresolvedImportsMode, load_config}; pub use fix::{FixResult, apply_fixes}; use std::path::Path; diff --git a/crates/codescythe_cli/BUILD.bazel b/crates/codescythe_cli/BUILD.bazel index 6a5daeb..9d97f6f 100644 --- a/crates/codescythe_cli/BUILD.bazel +++ b/crates/codescythe_cli/BUILD.bazel @@ -40,6 +40,7 @@ rust_test( crate_name = "codescythe_cli_e2e_test", data = [ ":codescythe", + "//tests/fixtures/runfiles-fixture:fixture_files", "//tests/fixtures/oxc-resolution:fixtures", ], edition = "2024", diff --git a/crates/codescythe_cli/e2e.rs b/crates/codescythe_cli/e2e.rs index 4e9c3ee..4959a66 100644 --- a/crates/codescythe_cli/e2e.rs +++ b/crates/codescythe_cli/e2e.rs @@ -54,6 +54,48 @@ fn cli_resolves_oxc_resolution_fixture() { .contains_key("unusedExtension")); } +#[test] +fn cli_uses_config_path_as_root_for_bazel_runfiles_fixture() { + let fixture = runfile("tests/fixtures/runfiles-fixture"); + let config = fixture.join("codescythe.json"); + let output = Command::new(runfile("crates/codescythe_cli/codescythe")) + .args([ + "--config", + path_arg(&config), + "--json", + "--compact-json", + ]) + .output() + .expect("failed to run codescythe CLI"); + + assert_eq!(output.status.code(), Some(1), "{}", output_text(&output)); + assert!( + output.stderr.is_empty(), + "unexpected stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let analysis: Value = + serde_json::from_slice(&output.stdout).expect("CLI stdout should be JSON"); + assert_eq!(analysis["counters"]["unresolved"], 1); + assert_eq!( + analysis["issues"]["unresolved"] + ["workspace/frontend/apps/client/platform/platformRuntime.ts"], + serde_json::json!(["./missing"]) + ); + + let files = analysis["issues"]["files"] + .as_object() + .expect("files should be an object"); + assert!(files.contains_key("workspace/frontend/dead.ts")); + assert!(files.contains_key("protobuf/wrong/client.ts")); + assert!(files.contains_key("typespec/schema.ts")); + assert!(!files.contains_key("workspace/frontend/lib/runtime.ts")); + assert!(!files.contains_key("protobuf/generated/client.ts")); + assert!(!files + .contains_key("workspace/frontend/apps/client/platform/platformRuntime.ts")); +} + fn runfile(relative: &str) -> PathBuf { let relative = Path::new(relative); let mut candidates = Vec::new(); diff --git a/crates/codescythe_cli/main.rs b/crates/codescythe_cli/main.rs index d538bcc..e956a6a 100644 --- a/crates/codescythe_cli/main.rs +++ b/crates/codescythe_cli/main.rs @@ -156,7 +156,7 @@ mod tests { #[test] fn explicit_directory_overrides_config_parent() { let directory = Path::new("/tmp/runfiles/_main"); - let config = Path::new("/tmp/runfiles/_main/pplx/frontend/codescythe.json"); + let config = Path::new("/tmp/runfiles/_main/workspace/frontend/codescythe.json"); let analysis_root = analysis_root(Some(directory), Some(config)).unwrap(); diff --git a/tests/bazel/BUILD.bazel b/tests/bazel/BUILD.bazel new file mode 100644 index 0000000..ffd0fb0 --- /dev/null +++ b/tests/bazel/BUILD.bazel @@ -0,0 +1 @@ +package(default_visibility = ["//visibility:public"]) diff --git a/tests/bazel/codescythe_test.bzl b/tests/bazel/codescythe_test.bzl new file mode 100644 index 0000000..7b41841 --- /dev/null +++ b/tests/bazel/codescythe_test.bzl @@ -0,0 +1,175 @@ +CodescytheSourcesInfo = provider( + doc = "Source files collected for a Codescythe runfiles test.", + fields = { + "sources": "depset of source files", + }, +) + +def _source_group_impl(ctx): + return [ + DefaultInfo( + files = depset( + ctx.files.srcs, + transitive = [dep[DefaultInfo].files for dep in ctx.attr.deps], + ), + ), + ] + +source_group = rule( + implementation = _source_group_impl, + attrs = { + "deps": attr.label_list(), + "srcs": attr.label_list(allow_files = True), + }, +) + +def _codescythe_sources_aspect_impl(target, ctx): + transitive = [] + if hasattr(ctx.rule.attr, "deps"): + transitive.extend([ + dep[CodescytheSourcesInfo].sources + for dep in ctx.rule.attr.deps + if CodescytheSourcesInfo in dep + ]) + + direct = [] + if hasattr(ctx.rule.attr, "srcs"): + for src in ctx.rule.attr.srcs: + direct.extend(src.files.to_list()) + + return [ + CodescytheSourcesInfo( + sources = depset(direct, transitive = transitive), + ), + ] + +_codescythe_sources_aspect = aspect( + implementation = _codescythe_sources_aspect_impl, + attr_aspects = ["deps"], +) + +def _codescythe_test_impl(ctx): + source_depsets = [ + target[CodescytheSourcesInfo].sources + for target in ctx.attr.targets + if CodescytheSourcesInfo in target + ] + script = ctx.actions.declare_file(ctx.label.name + ".sh") + ctx.actions.write( + output = script, + is_executable = True, + content = _test_script( + codescythe = ctx.executable._codescythe.short_path, + config = ctx.file.config.short_path, + expected_exit_code = ctx.attr.expected_exit_code, + must_contain = ctx.attr.must_contain, + must_not_contain = ctx.attr.must_not_contain, + ), + ) + + runfiles = ctx.runfiles( + files = [ctx.executable._codescythe, ctx.file.config] + ctx.files.data, + transitive_files = depset(transitive = source_depsets), + ).merge(ctx.attr._codescythe[DefaultInfo].default_runfiles) + + return [DefaultInfo(executable = script, runfiles = runfiles)] + +codescythe_test = rule( + implementation = _codescythe_test_impl, + attrs = { + "config": attr.label(allow_single_file = True, mandatory = True), + "data": attr.label_list(allow_files = True), + "expected_exit_code": attr.int(default = 0), + "must_contain": attr.string_list(), + "must_not_contain": attr.string_list(), + "targets": attr.label_list( + aspects = [_codescythe_sources_aspect], + mandatory = True, + ), + "_codescythe": attr.label( + default = Label("//crates/codescythe_cli:codescythe"), + executable = True, + cfg = "exec", + ), + }, + test = True, +) + +def _test_script(codescythe, config, expected_exit_code, must_contain, must_not_contain): + return """#!/usr/bin/env bash +set -euo pipefail + +runfile() {{ + local path="$1" + if [[ -n "${{RUNFILES_DIR:-}}" && -e "${{RUNFILES_DIR}}/${{path}}" ]]; then + printf '%s\\n' "${{RUNFILES_DIR}}/${{path}}" + return + fi + if [[ -n "${{RUNFILES_DIR:-}}" ]]; then + for workspace in "${{TEST_WORKSPACE:-_main}}" _main codescythe; do + if [[ -e "${{RUNFILES_DIR}}/${{workspace}}/${{path}}" ]]; then + printf '%s\\n' "${{RUNFILES_DIR}}/${{workspace}}/${{path}}" + return + fi + done + fi + if [[ -n "${{TEST_SRCDIR:-}}" ]]; then + for workspace in "${{TEST_WORKSPACE:-_main}}" _main codescythe; do + if [[ -e "${{TEST_SRCDIR}}/${{workspace}}/${{path}}" ]]; then + printf '%s\\n' "${{TEST_SRCDIR}}/${{workspace}}/${{path}}" + return + fi + done + fi + printf '%s\\n' "${{path}}" +}} + +codescythe="$(runfile {codescythe})" +config="$(runfile {config})" +stdout="${{TEST_TMPDIR}}/codescythe.stdout.json" +stderr="${{TEST_TMPDIR}}/codescythe.stderr.txt" + +set +e +"${{codescythe}}" --config "${{config}}" --json --compact-json >"${{stdout}}" 2>"${{stderr}}" +status="$?" +set -e + +if [[ "${{status}}" -ne {expected_exit_code} ]]; then + echo "expected exit code {expected_exit_code}, got ${{status}}" >&2 + cat "${{stdout}}" >&2 + cat "${{stderr}}" >&2 + exit 1 +fi + +if [[ -s "${{stderr}}" ]]; then + cat "${{stderr}}" >&2 + exit 1 +fi + +must_contain=({must_contain}) +for needle in "${{must_contain[@]}}"; do + if ! grep -F -- "${{needle}}" "${{stdout}}" >/dev/null; then + echo "expected Codescythe output to contain: ${{needle}}" >&2 + cat "${{stdout}}" >&2 + exit 1 + fi +done + +must_not_contain=({must_not_contain}) +for needle in "${{must_not_contain[@]}}"; do + if grep -F -- "${{needle}}" "${{stdout}}" >/dev/null; then + echo "expected Codescythe output not to contain: ${{needle}}" >&2 + cat "${{stdout}}" >&2 + exit 1 + fi +done +""".format( + codescythe = _shell_quote(codescythe), + config = _shell_quote(config), + expected_exit_code = expected_exit_code, + must_contain = " ".join([_shell_quote(value) for value in must_contain]), + must_not_contain = " ".join([_shell_quote(value) for value in must_not_contain]), + ) + +def _shell_quote(value): + return "'" + value.replace("'", "'\\''") + "'" diff --git a/tests/fixtures/runfiles-false-positive/BUILD.bazel b/tests/fixtures/runfiles-false-positive/BUILD.bazel new file mode 100644 index 0000000..541d767 --- /dev/null +++ b/tests/fixtures/runfiles-false-positive/BUILD.bazel @@ -0,0 +1,48 @@ +load("//tests/bazel:codescythe_test.bzl", "codescythe_test", "source_group") + +source_group( + name = "app_sources", + srcs = [ + "src/main.ts", + ], + deps = [ + ":generated_sources", + ":library_sources", + ], +) + +source_group( + name = "generated_sources", + srcs = [ + "generated/client.ts", + ], +) + +source_group( + name = "library_sources", + srcs = [ + "src/used.ts", + ], +) + +source_group( + name = "all_sources", + deps = [ + ":app_sources", + ], +) + +codescythe_test( + name = "codescythe_false_positive_test", + config = "codescythe.json", + data = ["package.json"], + expected_exit_code = 0, + must_contain = [ + "\"processed\":3", + "\"total\":3", + ], + must_not_contain = [ + "#virtual_generated/api/foo", + ], + targets = [":all_sources"], +) diff --git a/tests/fixtures/runfiles-false-positive/codescythe.json b/tests/fixtures/runfiles-false-positive/codescythe.json new file mode 100644 index 0000000..b459a4d --- /dev/null +++ b/tests/fixtures/runfiles-false-positive/codescythe.json @@ -0,0 +1,13 @@ +{ + "$schema": "../../../codescythe.schema.json", + "entry": "src/main.ts", + "project": [ + "src/**/*.ts", + "generated/**/*.ts" + ], + "aliases": { + "#generated/*": "./generated/*.ts" + }, + "ignoreUnresolved": ["#virtual_generated/**"], + "unresolvedImports": "report" +} diff --git a/tests/fixtures/runfiles-false-positive/generated/client.ts b/tests/fixtures/runfiles-false-positive/generated/client.ts new file mode 100644 index 0000000..440fd31 --- /dev/null +++ b/tests/fixtures/runfiles-false-positive/generated/client.ts @@ -0,0 +1 @@ +export const client = 'client'; diff --git a/tests/fixtures/runfiles-false-positive/package.json b/tests/fixtures/runfiles-false-positive/package.json new file mode 100644 index 0000000..2213092 --- /dev/null +++ b/tests/fixtures/runfiles-false-positive/package.json @@ -0,0 +1,7 @@ +{ + "name": "codescythe-bazel-runfiles-false-positive-fixture", + "type": "module", + "imports": { + "#app/*": "./src/*.ts" + } +} diff --git a/tests/fixtures/runfiles-false-positive/src/main.ts b/tests/fixtures/runfiles-false-positive/src/main.ts new file mode 100644 index 0000000..0fd58db --- /dev/null +++ b/tests/fixtures/runfiles-false-positive/src/main.ts @@ -0,0 +1,5 @@ +import { used } from '#app/used'; +import { client } from '#generated/client'; +import '#virtual_generated/api/foo'; + +console.log(used, client); diff --git a/tests/fixtures/runfiles-false-positive/src/used.ts b/tests/fixtures/runfiles-false-positive/src/used.ts new file mode 100644 index 0000000..a81bcab --- /dev/null +++ b/tests/fixtures/runfiles-false-positive/src/used.ts @@ -0,0 +1 @@ +export const used = 'used'; diff --git a/tests/fixtures/runfiles-fixture/BUILD.bazel b/tests/fixtures/runfiles-fixture/BUILD.bazel new file mode 100644 index 0000000..3425038 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/BUILD.bazel @@ -0,0 +1,80 @@ +load("//tests/bazel:codescythe_test.bzl", "codescythe_test", "source_group") + +source_group( + name = "entry_sources", + srcs = [ + "workspace/frontend/apps/client/platform/platformRuntime.ts", + ], + deps = [ + ":generated_sources", + ":library_sources", + ], +) + +source_group( + name = "generated_sources", + srcs = [ + "protobuf/generated/client.ts", + ], +) + +source_group( + name = "library_sources", + srcs = [ + "workspace/frontend/lib/runtime.ts", + ], +) + +source_group( + name = "unused_sources", + srcs = [ + "protobuf/wrong/client.ts", + "typespec/schema.ts", + "workspace/frontend/dead.ts", + ], +) + +source_group( + name = "all_sources", + deps = [ + ":entry_sources", + ":unused_sources", + ], +) + +filegroup( + name = "fixture_files", + srcs = [ + ":all_sources", + "codescythe.json", + "package.json", + "workspace/frontend/apps/client/index.html", + "workspace/frontend/apps/client/platform/index.html", + "workspace/frontend/apps/client/spa/index.html", + ], + visibility = ["//visibility:public"], +) + +codescythe_test( + name = "codescythe_runfiles_test", + config = "codescythe.json", + data = [ + "package.json", + "workspace/frontend/apps/client/index.html", + "workspace/frontend/apps/client/platform/index.html", + "workspace/frontend/apps/client/spa/index.html", + ], + expected_exit_code = 1, + must_contain = [ + "./missing", + "\"processed\":6", + "\"total\":6", + "protobuf/wrong/client.ts", + "typespec/schema.ts", + "workspace/frontend/dead.ts", + ], + must_not_contain = [ + "#virtual_generated/api/foo", + ], + targets = [":all_sources"], +) diff --git a/tests/fixtures/runfiles-fixture/README.md b/tests/fixtures/runfiles-fixture/README.md new file mode 100644 index 0000000..965c521 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/README.md @@ -0,0 +1,6 @@ +# Bazel Runfiles Fixture + +This fixture mirrors a Bazel runfiles setup: the CLI is pointed at a root config +file, root `package.json#imports` are available, explicit aliases can override +package imports, generated namespaces can be ignored, and runtime-only leaves +remain explicit entries. diff --git a/tests/fixtures/runfiles-fixture/codescythe.json b/tests/fixtures/runfiles-fixture/codescythe.json new file mode 100644 index 0000000..21b2384 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/codescythe.json @@ -0,0 +1,15 @@ +{ + "$schema": "../../../codescythe.schema.json", + "entry": [ + "workspace/frontend/apps/client/index.html", + "workspace/frontend/apps/client/spa/index.html", + "workspace/frontend/apps/client/platform/index.html", + "workspace/frontend/apps/client/platform/platformRuntime.ts" + ], + "project": "**/*.ts", + "aliases": { + "#bazel_generated/*": "./protobuf/generated/*.ts" + }, + "ignoreUnresolved": ["#virtual_generated/**"], + "unresolvedImports": "report" +} diff --git a/tests/fixtures/runfiles-fixture/package.json b/tests/fixtures/runfiles-fixture/package.json new file mode 100644 index 0000000..944ea70 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/package.json @@ -0,0 +1,8 @@ +{ + "name": "codescythe-bazel-runfiles-fixture", + "type": "module", + "imports": { + "#app/*": "./workspace/frontend/lib/*.ts", + "#bazel_generated/*": "./protobuf/wrong/*.ts" + } +} diff --git a/tests/fixtures/runfiles-fixture/protobuf/generated/client.ts b/tests/fixtures/runfiles-fixture/protobuf/generated/client.ts new file mode 100644 index 0000000..14f613d --- /dev/null +++ b/tests/fixtures/runfiles-fixture/protobuf/generated/client.ts @@ -0,0 +1,2 @@ +export const client = 'client'; +export const unusedClient = 'unused-client'; diff --git a/tests/fixtures/runfiles-fixture/protobuf/wrong/client.ts b/tests/fixtures/runfiles-fixture/protobuf/wrong/client.ts new file mode 100644 index 0000000..1d70a57 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/protobuf/wrong/client.ts @@ -0,0 +1 @@ +export const client = 'wrong-client'; diff --git a/tests/fixtures/runfiles-fixture/typespec/schema.ts b/tests/fixtures/runfiles-fixture/typespec/schema.ts new file mode 100644 index 0000000..fe33a2b --- /dev/null +++ b/tests/fixtures/runfiles-fixture/typespec/schema.ts @@ -0,0 +1 @@ +export const schema = 'schema'; diff --git a/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/index.html b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/index.html new file mode 100644 index 0000000..8998d3c --- /dev/null +++ b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/index.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/platform/index.html b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/platform/index.html new file mode 100644 index 0000000..1fd7b38 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/platform/index.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/platform/platformRuntime.ts b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/platform/platformRuntime.ts new file mode 100644 index 0000000..376695b --- /dev/null +++ b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/platform/platformRuntime.ts @@ -0,0 +1,6 @@ +import { runtime } from '#app/runtime'; +import { client } from '#bazel_generated/client'; +import '#virtual_generated/api/foo'; +import './missing'; + +console.log(runtime, client); diff --git a/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/spa/index.html b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/spa/index.html new file mode 100644 index 0000000..0c17a84 --- /dev/null +++ b/tests/fixtures/runfiles-fixture/workspace/frontend/apps/client/spa/index.html @@ -0,0 +1 @@ + diff --git a/tests/fixtures/runfiles-fixture/workspace/frontend/dead.ts b/tests/fixtures/runfiles-fixture/workspace/frontend/dead.ts new file mode 100644 index 0000000..c313dac --- /dev/null +++ b/tests/fixtures/runfiles-fixture/workspace/frontend/dead.ts @@ -0,0 +1 @@ +export const dead = 'dead'; diff --git a/tests/fixtures/runfiles-fixture/workspace/frontend/lib/runtime.ts b/tests/fixtures/runfiles-fixture/workspace/frontend/lib/runtime.ts new file mode 100644 index 0000000..2db76df --- /dev/null +++ b/tests/fixtures/runfiles-fixture/workspace/frontend/lib/runtime.ts @@ -0,0 +1,2 @@ +export const runtime = 'runtime'; +export const unusedRuntime = 'unused-runtime';