use std::any::Any;
use std::collections::{HashMap, HashSet};
use std::env;
use std::fs;
use std::panic::{self, AssertUnwindSafe};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{
Arc, Mutex,
atomic::{AtomicBool, AtomicUsize, Ordering},
mpsc,
};
use std::thread;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use shuck::shellcheck_runtime::{
ShellCheckDiagnostic, ShellCheckProbe, ShellCheckRun, decode_shellcheck_diagnostics,
parse_shellcheck_supported_shells, probe_shellcheck, run_shellcheck_json1 as run_shellcheck,
shellcheck_parse_aborted, shellcheck_supported_shells,
};
const LARGE_CORPUS_ENV: &str = "SHUCK_TEST_LARGE_CORPUS";
const LARGE_CORPUS_ROOT_ENV: &str = "SHUCK_LARGE_CORPUS_ROOT";
const LARGE_CORPUS_SHELLCHECK_TIMEOUT_ENV: &str = "SHUCK_LARGE_CORPUS_TIMEOUT_SECS";
const LARGE_CORPUS_SHUCK_TIMEOUT_ENV: &str = "SHUCK_LARGE_CORPUS_SHUCK_TIMEOUT_SECS";
const LARGE_CORPUS_SHARD_ENV: &str = "TEST_SHARD_INDEX";
const LARGE_CORPUS_SHARDS_ENV: &str = "TEST_TOTAL_SHARDS";
const LARGE_CORPUS_RULES_ENV: &str = "SHUCK_LARGE_CORPUS_RULES";
const LARGE_CORPUS_SAMPLE_PERCENT_ENV: &str = "SHUCK_LARGE_CORPUS_SAMPLE_PERCENT";
const LARGE_CORPUS_MAPPED_ONLY_ENV: &str = "SHUCK_LARGE_CORPUS_MAPPED_ONLY";
const LARGE_CORPUS_KEEP_GOING_ENV: &str = "SHUCK_LARGE_CORPUS_KEEP_GOING";
const LARGE_CORPUS_TIMING_ENV: &str = "SHUCK_LARGE_CORPUS_TIMING";
const LARGE_CORPUS_DEFAULT_SHELLCHECK_TIMEOUT: Duration = Duration::from_secs(300);
const LARGE_CORPUS_DEFAULT_SHUCK_TIMEOUT: Duration = Duration::from_secs(30);
const LARGE_CORPUS_AUTOSCALED_SHUCK_TIMEOUT_BUFFER: Duration = Duration::from_secs(15);
const LARGE_CORPUS_MAX_AUTOSCALED_SHUCK_TIMEOUT: Duration = Duration::from_secs(150);
const LARGE_CORPUS_AUTOSCALED_SHUCK_LINES_PER_SEC: usize = 175;
const LARGE_CORPUS_CACHE_DIR_NAME: &str = ".cache/large-corpus";
const LARGE_CORPUS_MAX_WORKER_COUNT: usize = 4;
const LARGE_CORPUS_TIMEOUT_FAILURE_CAP: usize = 5;
const LARGE_CORPUS_PROGRESS_PERCENT_STEP: usize = 5;
const LARGE_CORPUS_PROGRESS_BUCKET_COUNT: usize = 100 / LARGE_CORPUS_PROGRESS_PERCENT_STEP;
const LARGE_CORPUS_TIMING_LIMIT: usize = 25;
const RULE_CORPUS_METADATA_DIR: &str = "tests/testdata/corpus-metadata";
const LARGE_CORPUS_ALLOWED_FAILING_RULE_REASON: &str = "known large-corpus rule allowlist";
const LARGE_CORPUS_C001_COMPAT_NOTE: &str = "large corpus compatibility note: C001 is evaluated with ShellCheck-compatible indirect-expansion handling.";
const LARGE_CORPUS_STATIC_IGNORE_SUFFIXES: &[&str] = &[
"super-linter__super-linter__test__linters__bash__shell_bad_1.sh",
"super-linter__super-linter__test__linters__bash_exec__shell_bad_1.sh",
"alpinelinux__aports__community__starship__starship.plugin.zsh",
"google__oss-fuzz__infra__chronos__coverage_test_collection.py",
"ohmyzsh__ohmyzsh__plugins__alias-finder__tests__test_run.sh",
];
const LARGE_CORPUS_STATIC_ZSH_OVERRIDE_SUFFIXES: &[&str] = &[
"ohmyzsh__ohmyzsh__oh-my-zsh.sh",
"ohmyzsh__ohmyzsh__tools__check_for_upgrade.sh",
];
const LARGE_CORPUS_REVIEWED_SHELLCHECK_PARSE_ABORT_SUFFIXES: &[&str] = &[
"SlackBuildsOrg__slackbuilds__network__modemu2k__modemu2k.SlackBuild",
"SlackBuildsOrg__slackbuilds__system__firmware-gobi-2000__firmware-gobi-2000.SlackBuild",
"alpinelinux__aports__community__dnscrypt-proxy__dnscrypt-proxy.setup",
"bittorf__kalua__openwrt-addons__www__cgi-bin-status.sh",
"docker__docker-bench-security__tests__2_docker_daemon_configuration.sh",
"google__oss-fuzz__projects__apache-tika__build.sh",
"leebaird__discover__passive.sh",
"nvm-sh__nvm__test__install_script__install_nvm_from_git",
"pyenv__pyenv__plugins__python-build__bin__python-build",
"romkatv__powerlevel10k__gitstatus__install",
"rvm__rvm__scripts__functions__build_config_system",
"rvm__rvm__scripts__functions__requirements__gentoo_portage",
"rvm__rvm__scripts__functions__rvmrc",
];
const LARGE_CORPUS_REVIEWED_SHUCK_PARSE_EXCEPTION_SUFFIXES: &[&str] =
&["paulirish__dotfiles__.git-completion.bash"];
const SHELLCHECK_CACHE_SCHEMA: u32 = 3;
const SHELLCHECK_CACHE_MIGRATION_VERSION: u32 = 1;
#[derive(Debug, Clone)]
struct LargeCorpusConfig {
corpus_dir: PathBuf,
cache_dir: PathBuf,
shellcheck_timeout: Duration,
shuck_timeout: Duration,
shard_index: usize,
total_shards: usize,
selected_rules: Option<shuck_linter::RuleSet>,
sample_percent: usize,
mapped_only: bool,
keep_going: bool,
timing_mode: bool,
}
#[derive(Debug, Clone)]
struct LargeCorpusFixture {
path: PathBuf,
cache_rel_path: PathBuf,
shell: String,
source_hash: String,
}
impl LargeCorpusFixture {
fn cache_rel_path_key(&self) -> String {
normalize_cache_rel_path(&self.cache_rel_path)
}
}
#[derive(Debug)]
struct LargeCorpusPathResolver {
cache_rel_by_path: HashMap<PathBuf, PathBuf>,
path_by_cache_rel: HashMap<PathBuf, PathBuf>,
}
impl LargeCorpusPathResolver {
fn new(fixtures: &[&LargeCorpusFixture]) -> Self {
let mut cache_rel_by_path = HashMap::new();
let mut path_by_cache_rel = HashMap::new();
for fixture in fixtures {
let canonical_path = canonicalize_for_resolver(&fixture.path);
cache_rel_by_path.insert(fixture.path.clone(), fixture.cache_rel_path.clone());
cache_rel_by_path.insert(canonical_path.clone(), fixture.cache_rel_path.clone());
path_by_cache_rel.insert(fixture.cache_rel_path.clone(), canonical_path);
}
Self {
cache_rel_by_path,
path_by_cache_rel,
}
}
}
impl shuck_semantic::SourcePathResolver for LargeCorpusPathResolver {
fn resolve_candidate_paths(&self, source_path: &Path, candidate: &str) -> Vec<PathBuf> {
let Some(source_cache_rel_path) = self.cache_rel_by_path.get(source_path) else {
return Vec::new();
};
let mut resolved = Vec::new();
let mut seen = HashSet::new();
for candidate_cache_rel_path in [
resolve_large_corpus_candidate_cache_rel_path(source_cache_rel_path, candidate),
resolve_large_corpus_repo_relative_candidate_cache_rel_path(
source_cache_rel_path,
candidate,
),
]
.into_iter()
.flatten()
{
if let Some(path) = self.path_by_cache_rel.get(&candidate_cache_rel_path)
&& seen.insert(path.clone())
{
resolved.push(path.clone());
}
}
resolved
}
}
fn canonicalize_for_resolver(path: &Path) -> PathBuf {
fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
}
fn resolve_large_corpus_candidate_cache_rel_path(
source_cache_rel_path: &Path,
candidate: &str,
) -> Option<PathBuf> {
let candidate_path = Path::new(candidate);
if candidate_path.is_absolute() {
return None;
}
let mut resolved = source_cache_rel_path
.parent()
.map(PathBuf::from)
.unwrap_or_default();
let mut saw_component = false;
for component in candidate_path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
if !resolved.pop() {
return None;
}
saw_component = true;
}
std::path::Component::Normal(part) => {
resolved.push(part);
saw_component = true;
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
}
}
saw_component.then_some(resolved)
}
fn resolve_large_corpus_repo_relative_candidate_cache_rel_path(
source_cache_rel_path: &Path,
candidate: &str,
) -> Option<PathBuf> {
if source_cache_rel_path.components().count() != 1 {
return None;
}
let source_name = source_cache_rel_path.file_name()?.to_str()?;
let mut source_parts = source_name.split("__");
let owner = source_parts.next()?;
let repo = source_parts.next()?;
let candidate_path = Path::new(candidate);
if candidate_path.is_absolute() {
return None;
}
let mut flattened = Vec::new();
for component in candidate_path.components() {
match component {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => return None,
std::path::Component::Normal(part) => {
flattened.push(part.to_string_lossy().into_owned());
}
std::path::Component::RootDir | std::path::Component::Prefix(_) => return None,
}
}
(!flattened.is_empty())
.then(|| PathBuf::from(format!("{owner}__{repo}__{}", flattened.join("__"))))
}
static LARGE_CORPUS_PROGRESS_LOG: Mutex<()> = Mutex::new(());
struct LargeCorpusProgress {
total: usize,
completed: AtomicUsize,
logged_bucket: AtomicUsize,
}
impl LargeCorpusProgress {
fn new(total: usize) -> Self {
Self {
total,
completed: AtomicUsize::new(0),
logged_bucket: AtomicUsize::new(0),
}
}
fn finish_fixture(&self) {
let completed = self.completed.fetch_add(1, Ordering::Relaxed) + 1;
let bucket = progress_bucket(self.total, completed);
loop {
let logged_bucket = self.logged_bucket.load(Ordering::Relaxed);
if bucket <= logged_bucket {
return;
}
if self
.logged_bucket
.compare_exchange(logged_bucket, bucket, Ordering::Relaxed, Ordering::Relaxed)
.is_ok()
{
let percent = bucket * LARGE_CORPUS_PROGRESS_PERCENT_STEP;
log_large_corpus_progress(&format!(
"processed {completed}/{} fixtures ({percent}%)",
self.total
));
return;
}
}
}
}
fn log_large_corpus_progress(message: &str) {
let _guard = LARGE_CORPUS_PROGRESS_LOG
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
eprintln!("large corpus: {message}");
}
fn fixture_progress_label(fixture: &LargeCorpusFixture) -> String {
fixture.cache_rel_path_key()
}
fn log_large_corpus_timeout(fixture: &LargeCorpusFixture) {
log_large_corpus_progress(&format!("timeout {}", fixture_progress_label(fixture)));
}
fn source_line_count(source: &[u8]) -> usize {
if source.is_empty() {
return 0;
}
let newline_count = source.iter().filter(|&&byte| byte == b'\n').count();
if source.last() == Some(&b'\n') {
newline_count
} else {
newline_count + 1
}
}
fn effective_shuck_timeout(source: &[u8], base_timeout: Duration) -> Duration {
if env::var_os(LARGE_CORPUS_SHUCK_TIMEOUT_ENV).is_some() {
return base_timeout;
}
let line_count = source_line_count(source);
let scaled_timeout_secs = line_count.div_ceil(LARGE_CORPUS_AUTOSCALED_SHUCK_LINES_PER_SEC);
if scaled_timeout_secs <= base_timeout.as_secs() as usize {
return base_timeout;
}
let scaled_timeout = Duration::from_secs(scaled_timeout_secs as u64)
+ LARGE_CORPUS_AUTOSCALED_SHUCK_TIMEOUT_BUFFER;
scaled_timeout.min(LARGE_CORPUS_MAX_AUTOSCALED_SHUCK_TIMEOUT)
}
fn progress_bucket(total: usize, completed: usize) -> usize {
if total == 0 {
return 0;
}
let completed = completed.min(total) as u128;
let total = total as u128;
let bucket_count = LARGE_CORPUS_PROGRESS_BUCKET_COUNT as u128;
((completed * bucket_count) / total) as usize
}
fn format_fixture_elapsed(duration: Duration) -> String {
let millis = duration.as_millis();
if millis < 1_000 {
return format!("{millis}ms");
}
format!("{:.3}s", duration.as_secs_f64())
}
fn format_timeout_message(label: &str, timeout: Duration) -> String {
format!(
"{label} timed out after {}",
format_fixture_elapsed(timeout)
)
}
fn run_with_timeout<T, F>(label: &'static str, timeout: Duration, work: F) -> Result<T, String>
where
T: Send + 'static,
F: FnOnce() -> T + Send + 'static,
{
let (sender, receiver) = mpsc::sync_channel(1);
thread::spawn(move || {
let result = work();
let _ = sender.send(result);
});
match receiver.recv_timeout(timeout) {
Ok(result) => Ok(result),
Err(mpsc::RecvTimeoutError::Timeout) => Err(format_timeout_message(label, timeout)),
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err(format!("{label} worker thread exited before returning"))
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq, Default)]
#[serde(deny_unknown_fields)]
struct RuleCorpusMetadataDocument {
#[serde(default)]
review_all_divergences: bool,
#[serde(default)]
reviewed_divergences: Vec<ReviewedDivergenceRecord>,
}
impl RuleCorpusMetadataDocument {
fn has_entries(&self) -> bool {
self.review_all_divergences || !self.reviewed_divergences.is_empty()
}
fn validate(&self, rule_code: &str, path: &Path) -> Result<(), String> {
for (index, entry) in self.reviewed_divergences.iter().enumerate() {
entry.validate(rule_code, path, index + 1)?;
}
Ok(())
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")]
enum CompatibilitySide {
ShellcheckOnly,
ShuckOnly,
}
impl CompatibilitySide {
fn as_str(&self) -> &'static str {
match self {
Self::ShellcheckOnly => "shellcheck-only",
Self::ShuckOnly => "shuck-only",
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
struct ReviewedDivergenceRecord {
side: CompatibilitySide,
#[serde(default)]
rule_wide: bool,
#[serde(default)]
path_suffix: Option<String>,
#[serde(default)]
path_contains: Option<String>,
#[serde(default)]
line: Option<usize>,
#[serde(default)]
end_line: Option<usize>,
#[serde(default)]
column: Option<usize>,
#[serde(default)]
end_column: Option<usize>,
reason: String,
}
impl ReviewedDivergenceRecord {
fn has_scope(&self) -> bool {
self.path_suffix.is_some()
|| self.path_contains.is_some()
|| self.line.is_some()
|| self.end_line.is_some()
|| self.column.is_some()
|| self.end_column.is_some()
}
fn validate(&self, rule_code: &str, path: &Path, entry_index: usize) -> Result<(), String> {
match (self.rule_wide, self.has_scope()) {
(false, false) => Err(format!(
"invalid reviewed divergence {} entry {} in {}: add a scope field or set rule_wide: true",
rule_code,
entry_index,
path.display()
)),
(true, true) => Err(format!(
"invalid reviewed divergence {} entry {} in {}: rule-wide entries cannot also set path or range constraints",
rule_code,
entry_index,
path.display()
)),
_ => Ok(()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct DiagnosticRange {
line: usize,
end_line: usize,
column: usize,
end_column: usize,
}
impl DiagnosticRange {
fn display(&self) -> String {
format_range(self.line, self.column, self.end_line, self.end_column)
}
}
#[derive(Debug, Clone)]
struct CompatibilityRecord {
side: CompatibilitySide,
rule_code: Option<String>,
rule_codes: Vec<String>,
shellcheck_code: String,
range: DiagnosticRange,
message: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct CompatibilityRecordKey {
shellcheck_code: String,
range: DiagnosticRange,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CompatibilityClassification {
Implementation,
MappingIssue,
ReviewedDivergence,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ShellCheckCacheEntry {
schema: u32,
diagnostics: Vec<ShellCheckDiagnostic>,
parse_aborted: bool,
}
struct ShellCheckCache {
dir: PathBuf,
version_text: String,
legacy_invocation_hash: String,
}
impl ShellCheckCache {
fn new(cache_root: &Path, probe: &ShellCheckProbe) -> Self {
Self {
dir: cache_root.join("shellcheck"),
version_text: probe.version_text.clone(),
legacy_invocation_hash: legacy_shellcheck_invocation_hash(&probe.command),
}
}
fn prepare(&self, fixtures: &[LargeCorpusFixture], worktree_roots: &[PathBuf]) {
if fixtures.is_empty() {
return;
}
let _ = fs::create_dir_all(&self.dir);
let sentinel = self.migration_sentinel_path(fixtures);
if sentinel.is_file() {
return;
}
for fixture in fixtures {
let stable_path = self.cache_path(fixture);
let mut stable_exists = stable_path.is_file();
for legacy_path in self.legacy_cache_paths(fixture, worktree_roots) {
if !legacy_path.is_file() {
continue;
}
if stable_exists {
let _ = fs::remove_file(&legacy_path);
continue;
}
if let Some(parent) = stable_path.parent() {
let _ = fs::create_dir_all(parent);
}
match fs::rename(&legacy_path, &stable_path) {
Ok(()) => stable_exists = true,
Err(_) if stable_path.is_file() => {
stable_exists = true;
let _ = fs::remove_file(&legacy_path);
}
Err(_) => {}
}
}
}
let _ = fs::write(&sentinel, shellcheck_cache_migration_fingerprint(fixtures));
}
fn run_fixture(
&self,
fixture: &LargeCorpusFixture,
shellcheck_path: &str,
timeout: Duration,
) -> Result<ShellCheckRun, String> {
if let Some(cached) = self.read_cache(fixture) {
return Ok(cached);
}
let run = run_shellcheck(&fixture.path, &fixture.shell, shellcheck_path, timeout)?;
self.write_cache(fixture, &run);
Ok(run)
}
fn cache_path(&self, fixture: &LargeCorpusFixture) -> PathBuf {
let key_data = serde_json::json!({
"schema": SHELLCHECK_CACHE_SCHEMA,
"path": fixture.cache_rel_path_key(),
"shell": fixture.shell,
"sourceHash": fixture.source_hash,
"versionText": self.version_text,
});
let key = hash_bytes(key_data.to_string().as_bytes());
self.dir.join(format!("{key}.json"))
}
fn legacy_cache_paths(
&self,
fixture: &LargeCorpusFixture,
worktree_roots: &[PathBuf],
) -> Vec<PathBuf> {
let mut legacy_paths = Vec::new();
let mut seen = HashSet::new();
let direct = self.legacy_cache_path_for_absolute_path(fixture, &fixture.path);
if seen.insert(direct.clone()) {
legacy_paths.push(direct);
}
for absolute_path in
projected_worktree_fixture_paths(&fixture.cache_rel_path, worktree_roots)
{
let legacy = self.legacy_cache_path_for_absolute_path(fixture, &absolute_path);
if seen.insert(legacy.clone()) {
legacy_paths.push(legacy);
}
}
legacy_paths
}
fn legacy_cache_path_for_absolute_path(
&self,
fixture: &LargeCorpusFixture,
absolute_path: &Path,
) -> PathBuf {
let key_data = serde_json::json!({
"schema": SHELLCHECK_CACHE_SCHEMA,
"path": absolute_path.to_string_lossy(),
"shell": fixture.shell,
"sourceHash": fixture.source_hash,
"invocationHash": self.legacy_invocation_hash,
});
let key = hash_bytes(key_data.to_string().as_bytes());
self.dir.join(format!("{key}.json"))
}
fn migration_sentinel_path(&self, fixtures: &[LargeCorpusFixture]) -> PathBuf {
let fingerprint = shellcheck_cache_migration_fingerprint(fixtures);
self.dir.join(format!(
".migration-v{SHELLCHECK_CACHE_MIGRATION_VERSION}-{fingerprint}.done"
))
}
fn read_cache(&self, fixture: &LargeCorpusFixture) -> Option<ShellCheckRun> {
let path = self.cache_path(fixture);
let data = fs::read_to_string(&path).ok()?;
let entry: ShellCheckCacheEntry = serde_json::from_str(&data).ok()?;
if entry.schema != SHELLCHECK_CACHE_SCHEMA {
return None;
}
Some(ShellCheckRun {
diagnostics: entry.diagnostics,
parse_aborted: entry.parse_aborted,
})
}
fn write_cache(&self, fixture: &LargeCorpusFixture, run: &ShellCheckRun) {
let path = self.cache_path(fixture);
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
}
let entry = ShellCheckCacheEntry {
schema: SHELLCHECK_CACHE_SCHEMA,
diagnostics: run.diagnostics.clone(),
parse_aborted: run.parse_aborted,
};
if let Ok(data) = serde_json::to_string(&entry) {
let _ = fs::write(&path, data);
}
}
}
struct ShuckRun {
diagnostics: Vec<shuck_linter::Diagnostic>,
parse_error: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FixtureFailureKind {
Other,
Timeout,
Warning,
}
impl FixtureFailureKind {
fn blocks(self) -> bool {
matches!(self, Self::Other)
}
}
#[derive(Debug, Clone)]
struct FixtureFailure {
message: String,
kind: FixtureFailureKind,
}
#[derive(Debug, Default)]
struct FixtureEvaluation {
implementation_diffs: Vec<String>,
mapping_issues: Vec<String>,
reviewed_divergences: Vec<String>,
harness_failure: Option<FixtureFailure>,
}
#[derive(Debug, Default)]
struct FixtureFailureCollection {
implementation_diffs: Vec<String>,
mapping_issues: Vec<String>,
reviewed_divergences: Vec<String>,
harness_warnings: Vec<String>,
harness_failures: Vec<String>,
unsupported_shells: usize,
timeout_cap_reached: bool,
}
impl FixtureFailureCollection {
fn blocking_failures(&self) -> usize {
self.implementation_diffs.len() + self.harness_failures.len()
}
fn nonblocking_issue_count(&self) -> usize {
self.mapping_issues.len() + self.reviewed_divergences.len() + self.harness_warnings.len()
}
fn has_nonblocking_items(&self) -> bool {
self.unsupported_shells > 0 || self.nonblocking_issue_count() > 0
}
}
#[derive(Clone, Copy)]
enum LargeCorpusReportMode {
Full,
BlockingOnly,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum LargeCorpusTimingOutcome {
Ok,
ParseError,
Timeout,
Error,
}
impl LargeCorpusTimingOutcome {
fn as_str(&self) -> &'static str {
match self {
Self::Ok => "ok",
Self::ParseError => "parse-error",
Self::Timeout => "timeout",
Self::Error => "error",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct LargeCorpusTimingRecord {
fixture_label: String,
elapsed: Duration,
outcome: LargeCorpusTimingOutcome,
}
#[derive(Debug, Default)]
struct LargeCorpusTimingCollection {
records: Vec<LargeCorpusTimingRecord>,
timeout_cap_reached: bool,
}
fn collect_fixture_timings(
fixtures: &[&LargeCorpusFixture],
shuck_timeout: Duration,
linter_settings: &shuck_linter::LinterSettings,
shuck_path_resolver: Arc<LargeCorpusPathResolver>,
) -> LargeCorpusTimingCollection {
collect_fixture_timing_records(fixtures, |fixture| {
measure_fixture_timing(
fixture,
shuck_timeout,
linter_settings,
Arc::clone(&shuck_path_resolver),
)
})
}
fn collect_fixture_timing_records<F>(
fixtures: &[&LargeCorpusFixture],
evaluate: F,
) -> LargeCorpusTimingCollection
where
F: Fn(&LargeCorpusFixture) -> LargeCorpusTimingRecord,
{
let mut collection = LargeCorpusTimingCollection::default();
let mut timeout_count = 0;
for fixture in fixtures {
let record = evaluate(fixture);
if record.outcome == LargeCorpusTimingOutcome::Timeout {
timeout_count += 1;
}
collection.records.push(record);
if timeout_count >= LARGE_CORPUS_TIMEOUT_FAILURE_CAP {
collection.timeout_cap_reached = true;
break;
}
}
collection
}
fn measure_fixture_timing(
fixture: &LargeCorpusFixture,
base_shuck_timeout: Duration,
linter_settings: &shuck_linter::LinterSettings,
shuck_path_resolver: Arc<LargeCorpusPathResolver>,
) -> LargeCorpusTimingRecord {
let start = Instant::now();
let outcome = match panic::catch_unwind(AssertUnwindSafe(|| {
let source = fs::read(&fixture.path).unwrap_or_default();
let shuck_timeout = effective_shuck_timeout(&source, base_shuck_timeout);
run_shuck_with_timeout(
fixture,
linter_settings,
shuck_timeout,
Arc::clone(&shuck_path_resolver),
)
})) {
Ok(Ok(run)) => {
if run.parse_error.is_some() {
LargeCorpusTimingOutcome::ParseError
} else {
LargeCorpusTimingOutcome::Ok
}
}
Ok(Err(err)) => {
if is_timeout_message(&err, "shuck") {
LargeCorpusTimingOutcome::Timeout
} else {
LargeCorpusTimingOutcome::Error
}
}
Err(_) => LargeCorpusTimingOutcome::Error,
};
LargeCorpusTimingRecord {
fixture_label: fixture_progress_label(fixture),
elapsed: start.elapsed(),
outcome,
}
}
fn ranked_large_corpus_timings(
records: &[LargeCorpusTimingRecord],
) -> Vec<LargeCorpusTimingRecord> {
let mut ranked = records.to_vec();
ranked.sort_by(|left, right| {
right
.elapsed
.cmp(&left.elapsed)
.then_with(|| left.fixture_label.cmp(&right.fixture_label))
});
ranked.truncate(LARGE_CORPUS_TIMING_LIMIT);
ranked
}
fn timing_timeout_cap_note() -> String {
format!(
"large corpus timing note: stopped after {} timed-out fixture(s) to avoid leaving additional timed-out workers running.",
LARGE_CORPUS_TIMEOUT_FAILURE_CAP
)
}
fn format_large_corpus_timing_report(collection: &LargeCorpusTimingCollection) -> String {
let ranked = ranked_large_corpus_timings(&collection.records);
if ranked.is_empty() {
return "large corpus timing: no supported fixtures selected".into();
}
let mut lines = vec![format!(
"large corpus timing: showing {} slowest shuck fixture(s) out of {} measured fixture(s)",
ranked.len(),
collection.records.len()
)];
lines.extend(ranked.iter().enumerate().map(|(index, record)| {
format!(
"{:>2}. {} [{}] {}",
index + 1,
format_fixture_elapsed(record.elapsed),
record.outcome.as_str(),
record.fixture_label
)
}));
if collection.timeout_cap_reached {
lines.push(timing_timeout_cap_note());
}
lines.join("\n")
}
fn select_supported_large_corpus_fixtures<'a>(
fixtures: &'a [LargeCorpusFixture],
shellcheck_supported_shells: Option<&HashMap<&'static str, ()>>,
) -> Vec<&'a LargeCorpusFixture> {
fixtures
.iter()
.filter(|fixture| fixture_supported_for_large_corpus(fixture, shellcheck_supported_shells))
.collect()
}
#[test]
#[ignore = "requires the large corpus; run `make test-large-corpus`"]
fn large_corpus_conforms_with_shellcheck() {
let cfg = match resolve_large_corpus_config() {
Some(cfg) => cfg,
None => {
eprintln!("large corpus test skipped (set {LARGE_CORPUS_ENV}=1 to enable)");
return;
}
};
let fixtures = load_fixtures(&cfg);
if fixtures.is_empty() {
panic!(
"no fixtures found in {}",
cfg.corpus_dir.join("scripts").display()
);
}
if cfg.timing_mode {
let supported_fixtures = select_supported_large_corpus_fixtures(&fixtures, None);
let linter_settings =
build_large_corpus_linter_settings(cfg.selected_rules, cfg.mapped_only);
let shuck_path_resolver = Arc::new(LargeCorpusPathResolver::new(&supported_fixtures));
let timings = collect_fixture_timings(
&supported_fixtures,
cfg.shuck_timeout,
&linter_settings,
shuck_path_resolver,
);
eprintln!("{}", format_large_corpus_timing_report(&timings));
return;
}
let shellcheck = probe_shellcheck()
.expect("shellcheck not found on PATH; install it to run the large corpus test");
let supported_shells = shellcheck_supported_shells(&shellcheck.command);
let shellcheck_index = build_rule_to_shellcheck_index(cfg.selected_rules.as_ref());
let shellcheck_rule_index = build_shellcheck_to_rule_index(cfg.selected_rules.as_ref());
let corpus_metadata = load_all_rule_corpus_metadata();
let shellcheck_filter_codes =
build_shellcheck_filter_codes(cfg.selected_rules, cfg.mapped_only);
let shellcheck_cache = ShellCheckCache::new(&cfg.cache_dir, &shellcheck);
shellcheck_cache.prepare(&fixtures, &discover_worktree_roots());
let linter_settings = build_large_corpus_linter_settings(cfg.selected_rules, cfg.mapped_only);
let supported_fixtures =
select_supported_large_corpus_fixtures(&fixtures, Some(&supported_shells));
let skipped_unsupported_shells = fixtures.len().saturating_sub(supported_fixtures.len());
let shuck_path_resolver = Arc::new(LargeCorpusPathResolver::new(&supported_fixtures));
let failure_collection =
collect_fixture_failures(&supported_fixtures, cfg.keep_going, |fixture| {
evaluate_fixture_compatibility(
fixture,
&shellcheck_cache,
&shellcheck.command,
cfg.shellcheck_timeout,
cfg.shuck_timeout,
&linter_settings,
&shellcheck_index,
&shellcheck_rule_index,
&corpus_metadata,
shellcheck_filter_codes.as_ref(),
Arc::clone(&shuck_path_resolver),
)
});
let mut failure_collection = failure_collection;
failure_collection.unsupported_shells = skipped_unsupported_shells;
let timeout_cap_note = timeout_cap_note_suffix(failure_collection.timeout_cap_reached);
eprintln!(
"large corpus compatibility summary: blocking={} warnings={} fixtures={} unsupported_shells={} implementation_diffs={} mapping_issues={} reviewed_divergences={} harness_warnings={} harness_failures={}",
failure_collection.blocking_failures(),
failure_collection.nonblocking_issue_count(),
fixtures.len(),
skipped_unsupported_shells,
failure_collection.implementation_diffs.len(),
failure_collection.mapping_issues.len(),
failure_collection.reviewed_divergences.len(),
failure_collection.harness_warnings.len(),
failure_collection.harness_failures.len(),
);
emit_large_corpus_c001_compat_note(&linter_settings);
emit_timeout_cap_note(
"large corpus compatibility",
failure_collection.timeout_cap_reached,
);
if failure_collection.blocking_failures() == 0
&& failure_collection.nonblocking_issue_count() > 0
{
eprintln!("{}", format_large_corpus_report(&failure_collection));
}
assert!(
failure_collection.blocking_failures() == 0,
"large corpus compatibility had {} blocking issue(s) across {} fixture(s) ({} skipped unsupported shells){}:\n\n{}",
failure_collection.blocking_failures(),
fixtures.len(),
skipped_unsupported_shells,
timeout_cap_note,
format_large_corpus_failure_report(&failure_collection)
);
}
#[test]
#[ignore = "requires the large corpus; run `make test-large-corpus-zsh`"]
fn large_corpus_zsh_fixtures_parse() {
let cfg = match resolve_large_corpus_config() {
Some(cfg) => cfg,
None => {
eprintln!("large corpus test skipped (set {LARGE_CORPUS_ENV}=1 to enable)");
return;
}
};
let fixtures = load_fixtures(&cfg);
if fixtures.is_empty() {
panic!(
"no fixtures found in {}",
cfg.corpus_dir.join("scripts").display()
);
}
let zsh_fixtures: Vec<_> = fixtures
.iter()
.filter(|fixture| fixture_selected_for_large_corpus_zsh_parse(fixture))
.collect();
if zsh_fixtures.is_empty() {
eprintln!("large corpus zsh parse skipped (no zsh fixtures found for this shard/sample)");
return;
}
let failure_collection = collect_fixture_failures(&zsh_fixtures, cfg.keep_going, |fixture| {
evaluate_fixture_zsh_parse(fixture, cfg.shuck_timeout)
});
let timeout_cap_note = timeout_cap_note_suffix(failure_collection.timeout_cap_reached);
emit_timeout_cap_note(
"large corpus zsh parse",
failure_collection.timeout_cap_reached,
);
assert!(
failure_collection.blocking_failures() == 0,
"large corpus zsh parse had {} blocking issue(s) across {} fixture(s){}:\n\n{}",
failure_collection.blocking_failures(),
zsh_fixtures.len(),
timeout_cap_note,
format_large_corpus_failure_report(&failure_collection)
);
}
fn collect_fixture_failures<F>(
fixtures: &[&LargeCorpusFixture],
keep_going: bool,
evaluate: F,
) -> FixtureFailureCollection
where
F: Fn(&LargeCorpusFixture) -> FixtureEvaluation + Sync,
{
let progress = LargeCorpusProgress::new(fixtures.len());
if keep_going {
return collect_fixture_failures_in_parallel(
fixtures,
large_corpus_worker_count(fixtures.len()),
&evaluate,
&progress,
);
}
collect_fixture_failures_sequential(fixtures, &evaluate, &progress)
}
fn large_corpus_worker_count(fixtures_len: usize) -> usize {
let available_parallelism = thread::available_parallelism()
.map(std::num::NonZeroUsize::get)
.unwrap_or(1);
clamp_large_corpus_worker_count(available_parallelism, fixtures_len)
}
fn clamp_large_corpus_worker_count(available_parallelism: usize, fixtures_len: usize) -> usize {
available_parallelism
.min(LARGE_CORPUS_MAX_WORKER_COUNT)
.min(fixtures_len)
}
fn collect_fixture_failures_sequential<F>(
fixtures: &[&LargeCorpusFixture],
evaluate: &F,
progress: &LargeCorpusProgress,
) -> FixtureFailureCollection
where
F: Fn(&LargeCorpusFixture) -> FixtureEvaluation,
{
let mut collection = FixtureFailureCollection::default();
for fixture in fixtures {
let evaluation = evaluate(fixture);
let has_blocking = evaluation
.harness_failure
.as_ref()
.is_some_and(|failure| failure.kind.blocks())
|| !evaluation.implementation_diffs.is_empty();
let timeout = evaluation
.harness_failure
.as_ref()
.is_some_and(|failure| failure.kind == FixtureFailureKind::Timeout);
progress.finish_fixture();
merge_fixture_evaluation(&mut collection, evaluation);
if has_blocking {
if timeout {
log_large_corpus_timeout(fixture);
}
panic!("{}", format_large_corpus_failure_report(&collection));
}
}
collection
}
fn collect_fixture_failures_in_parallel<F>(
fixtures: &[&LargeCorpusFixture],
worker_count: usize,
evaluate: &F,
progress: &LargeCorpusProgress,
) -> FixtureFailureCollection
where
F: Fn(&LargeCorpusFixture) -> FixtureEvaluation + Sync,
{
if fixtures.is_empty() {
return FixtureFailureCollection::default();
}
let worker_count = worker_count.max(1).min(fixtures.len());
let next_index = AtomicUsize::new(0);
let timeout_failures = AtomicUsize::new(0);
let timeout_cap_reached = AtomicBool::new(false);
let collection = Mutex::new(Vec::<(usize, FixtureEvaluation)>::new());
thread::scope(|scope| {
for _ in 0..worker_count {
let collection = &collection;
let next_index = &next_index;
let timeout_failures = &timeout_failures;
let timeout_cap_reached = &timeout_cap_reached;
scope.spawn(move || {
let mut local_evaluations = Vec::new();
loop {
let index = next_index.fetch_add(1, Ordering::Relaxed);
if index >= fixtures.len() {
break;
}
let fixture = fixtures[index];
let result = panic::catch_unwind(AssertUnwindSafe(|| evaluate(fixture)));
progress.finish_fixture();
match result {
Ok(evaluation) => {
if evaluation
.harness_failure
.as_ref()
.is_some_and(|failure| failure.kind == FixtureFailureKind::Timeout)
{
log_large_corpus_timeout(fixture);
let timeout_count =
timeout_failures.fetch_add(1, Ordering::Relaxed) + 1;
if timeout_count <= LARGE_CORPUS_TIMEOUT_FAILURE_CAP {
local_evaluations.push((index, evaluation));
}
if timeout_count >= LARGE_CORPUS_TIMEOUT_FAILURE_CAP {
timeout_cap_reached.store(true, Ordering::Relaxed);
}
continue;
}
local_evaluations.push((index, evaluation));
}
Err(payload) => {
local_evaluations.push((
index,
FixtureEvaluation {
harness_failure: Some(FixtureFailure {
kind: FixtureFailureKind::Other,
message: format_fixture_panic(fixture, payload),
}),
..FixtureEvaluation::default()
},
));
}
}
}
if !local_evaluations.is_empty() {
collection.lock().unwrap().extend(local_evaluations);
}
});
}
});
let mut evaluations = collection.into_inner().unwrap();
evaluations.sort_by_key(|(index, _)| *index);
let mut failures = FixtureFailureCollection::default();
for (_, evaluation) in evaluations {
merge_fixture_evaluation(&mut failures, evaluation);
}
failures.timeout_cap_reached = timeout_cap_reached.load(Ordering::Relaxed);
failures
}
fn format_fixture_panic(fixture: &LargeCorpusFixture, payload: Box<dyn Any + Send>) -> String {
format_fixture_failure(
&fixture.path,
&[format!("fixture panic: {}", panic_payload_message(payload))],
)
}
fn panic_payload_message(payload: Box<dyn Any + Send>) -> String {
let payload = &*payload;
if let Some(message) = payload.downcast_ref::<String>() {
return message.clone();
}
if let Some(message) = payload.downcast_ref::<&str>() {
return (*message).to_owned();
}
"non-string panic payload".into()
}
#[allow(clippy::too_many_arguments)]
fn evaluate_fixture_compatibility(
fixture: &LargeCorpusFixture,
shellcheck_cache: &ShellCheckCache,
shellcheck_path: &str,
shellcheck_timeout: Duration,
shuck_timeout: Duration,
linter_settings: &shuck_linter::LinterSettings,
shellcheck_index: &HashMap<String, String>,
shellcheck_rule_index: &HashMap<u32, Vec<String>>,
corpus_metadata: &HashMap<String, RuleCorpusMetadataDocument>,
shellcheck_filter_codes: Option<&HashSet<u32>>,
shuck_path_resolver: Arc<LargeCorpusPathResolver>,
) -> FixtureEvaluation {
let mut evaluation = FixtureEvaluation::default();
let src = fs::read(&fixture.path).unwrap_or_default();
let shuck_timeout = effective_shuck_timeout(&src, shuck_timeout);
let shuck_run = match run_shuck_with_timeout(
fixture,
linter_settings,
shuck_timeout,
shuck_path_resolver,
) {
Ok(run) => run,
Err(err) => {
evaluation.harness_failure = Some(FixtureFailure {
kind: fixture_failure_kind_for_message(&err, "shuck"),
message: format_fixture_failure(&fixture.path, &[err]),
});
return evaluation;
}
};
let shellcheck_run = match shuck_run.parse_error.as_deref() {
Some(err) if shuck_parse_aborted(err) => {
match shellcheck_cache.run_fixture(fixture, shellcheck_path, shellcheck_timeout) {
Ok(run) => Some(filter_shellcheck_run(run, shellcheck_filter_codes)),
Err(err) => {
evaluation.harness_failure = Some(FixtureFailure {
kind: fixture_failure_kind_for_message(&err, "shellcheck"),
message: format_fixture_failure(
&fixture.path,
&[format!("shellcheck error: {err}")],
),
});
return evaluation;
}
}
}
_ => None,
};
if let Some(ref err) = shuck_run.parse_error {
if shuck_parse_aborted(err) {
let Some(sc_run) = shellcheck_run.as_ref() else {
unreachable!("parse-aborted shuck runs should probe shellcheck")
};
let mut details = vec![format!("shuck parse error: {err}")];
if sc_run.parse_aborted {
details.push("shellcheck parse aborted".into());
} else {
details.push("shellcheck parsed fixture successfully".into());
}
if record_reviewed_large_corpus_harness_warning(
&mut evaluation,
&fixture.path,
&details,
) {
return evaluation;
}
evaluation.harness_failure = Some(FixtureFailure {
kind: FixtureFailureKind::Other,
message: format_fixture_failure(&fixture.path, &details),
});
} else {
evaluation.harness_failure = Some(FixtureFailure {
kind: FixtureFailureKind::Other,
message: format_fixture_failure(&fixture.path, &[format!("shuck error: {err}")]),
});
}
return evaluation;
}
match shellcheck_run.map(Ok).unwrap_or_else(|| {
shellcheck_cache
.run_fixture(fixture, shellcheck_path, shellcheck_timeout)
.map(|run| filter_shellcheck_run(run, shellcheck_filter_codes))
}) {
Ok(sc_run) => {
if sc_run.parse_aborted {
let details = vec!["shellcheck parse aborted".into()];
if record_reviewed_large_corpus_harness_warning(
&mut evaluation,
&fixture.path,
&details,
) {
return evaluation;
}
evaluation.harness_failure = Some(FixtureFailure {
kind: FixtureFailureKind::Other,
message: format_fixture_failure(&fixture.path, &details),
});
return evaluation;
}
let shellcheck_records =
shellcheck_compatibility_records(&sc_run.diagnostics, shellcheck_rule_index);
let shuck_records =
shuck_compatibility_records(&shuck_run.diagnostics, shellcheck_index);
let (shellcheck_only, shuck_only) =
unmatched_compatibility_records(&shellcheck_records, &shuck_records);
let cache_rel_path = fixture.cache_rel_path_key();
let mut implementation_details = Vec::new();
let mut mapping_details = Vec::new();
let mut reviewed_details = Vec::new();
for record in shellcheck_only.into_iter().chain(shuck_only) {
let (classification, reason) =
classify_compatibility_record(&record, &cache_rel_path, corpus_metadata);
let detail = format_compatibility_record(&record, reason.as_deref());
match classification {
CompatibilityClassification::Implementation => {
implementation_details.push(detail)
}
CompatibilityClassification::MappingIssue => mapping_details.push(detail),
CompatibilityClassification::ReviewedDivergence => {
reviewed_details.push(detail)
}
}
}
if !implementation_details.is_empty() {
evaluation.implementation_diffs.push(format_fixture_failure(
&fixture.path,
&implementation_details,
));
}
if !mapping_details.is_empty() {
evaluation
.mapping_issues
.push(format_fixture_failure(&fixture.path, &mapping_details));
}
if !reviewed_details.is_empty() {
evaluation
.reviewed_divergences
.push(format_fixture_failure(&fixture.path, &reviewed_details));
}
}
Err(err) => {
evaluation.harness_failure = Some(FixtureFailure {
kind: fixture_failure_kind_for_message(&err, "shellcheck"),
message: format_fixture_failure(
&fixture.path,
&[format!("shellcheck error: {err}")],
),
});
}
}
evaluation
}
fn evaluate_fixture_zsh_parse(
fixture: &LargeCorpusFixture,
shuck_timeout: Duration,
) -> FixtureEvaluation {
let mut evaluation = FixtureEvaluation::default();
let parse_result =
match parse_fixture_for_effective_large_corpus_shell_with_timeout(fixture, shuck_timeout) {
Ok(result) => result,
Err(err) => {
evaluation.harness_failure = Some(FixtureFailure {
kind: fixture_failure_kind_for_message(&err, "shuck"),
message: format_fixture_failure(&fixture.path, &[err]),
});
return evaluation;
}
};
if let Err(err) = parse_result {
match probe_invalid_zsh_fixture(&fixture.path, shuck_timeout) {
Ok(Some(zsh_err)) => {
evaluation.harness_failure = Some(FixtureFailure {
kind: FixtureFailureKind::Other,
message: format_fixture_failure(
&fixture.path,
&[
format!("shuck zsh parse/option extraction error: {err}"),
format!("zsh -n parse error: {zsh_err}"),
],
),
});
}
Ok(None) | Err(_) => {
evaluation.harness_failure = Some(FixtureFailure {
kind: FixtureFailureKind::Other,
message: format_fixture_failure(
&fixture.path,
&[format!("shuck zsh parse/option extraction error: {err}")],
),
});
}
}
}
evaluation
}
fn probe_invalid_zsh_fixture(path: &Path, timeout: Duration) -> Result<Option<String>, String> {
let path = path.to_path_buf();
let status = run_with_timeout("zsh", timeout, move || {
Command::new("zsh")
.arg("-n")
.arg(&path)
.output()
.map_err(|err| format!("failed to run `zsh -n`: {err}"))
})??;
if status.status.success() {
return Ok(None);
}
let stderr = String::from_utf8_lossy(&status.stderr).trim().to_owned();
if stderr.is_empty() || !zsh_stderr_looks_like_parse_error(&stderr) {
return Ok(None);
}
Ok(Some(stderr))
}
fn zsh_stderr_looks_like_parse_error(stderr: &str) -> bool {
let lower = stderr.to_ascii_lowercase();
lower.contains("parse error") || lower.contains("unmatched ")
}
fn fixture_failure_kind_for_message(message: &str, label: &str) -> FixtureFailureKind {
if is_timeout_message(message, label) {
FixtureFailureKind::Timeout
} else {
FixtureFailureKind::Other
}
}
fn is_timeout_message(message: &str, label: &str) -> bool {
message.starts_with(label) && message.contains(" timed out after ")
}
fn filter_shellcheck_run(
mut run: ShellCheckRun,
shellcheck_filter_codes: Option<&HashSet<u32>>,
) -> ShellCheckRun {
let Some(shellcheck_filter_codes) = shellcheck_filter_codes else {
return run;
};
run.diagnostics
.retain(|diag| shellcheck_filter_codes.contains(&diag.code));
run.parse_aborted |= shellcheck_parse_aborted(&run.diagnostics);
run
}
fn merge_fixture_evaluation(
collection: &mut FixtureFailureCollection,
evaluation: FixtureEvaluation,
) {
collection
.implementation_diffs
.extend(evaluation.implementation_diffs);
collection.mapping_issues.extend(evaluation.mapping_issues);
collection
.reviewed_divergences
.extend(evaluation.reviewed_divergences);
if let Some(failure) = evaluation.harness_failure {
match failure.kind {
FixtureFailureKind::Timeout | FixtureFailureKind::Warning => {
collection.harness_warnings.push(failure.message)
}
FixtureFailureKind::Other => collection.harness_failures.push(failure.message),
}
}
}
fn rule_corpus_metadata_path(rule_code: &str) -> PathBuf {
let filename = format!("{}.yaml", rule_code.to_ascii_lowercase());
Path::new(env!("CARGO_MANIFEST_DIR"))
.join(RULE_CORPUS_METADATA_DIR)
.join(filename)
}
fn load_rule_corpus_metadata(rule_code: &str) -> RuleCorpusMetadataDocument {
let path = rule_corpus_metadata_path(rule_code);
if !path.exists() {
return RuleCorpusMetadataDocument::default();
}
let data =
fs::read_to_string(&path).unwrap_or_else(|err| panic!("read {}: {err}", path.display()));
let metadata: RuleCorpusMetadataDocument =
serde_yaml::from_str(&data).unwrap_or_else(|err| panic!("parse {}: {err}", path.display()));
metadata
.validate(rule_code, &path)
.unwrap_or_else(|err| panic!("{err}"));
metadata
}
fn load_all_rule_corpus_metadata() -> HashMap<String, RuleCorpusMetadataDocument> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join(RULE_CORPUS_METADATA_DIR);
let mut map = HashMap::new();
let entries = match fs::read_dir(&dir) {
Ok(entries) => entries,
Err(_) => return map,
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "yaml")
&& let Some(stem) = path.file_stem().and_then(|s| s.to_str())
{
let rule_code = stem.to_ascii_uppercase();
let metadata = load_rule_corpus_metadata(&rule_code);
if metadata.has_entries() {
map.insert(rule_code, metadata);
}
}
}
map
}
fn reviewed_divergence_reason<'a>(
metadata: &'a RuleCorpusMetadataDocument,
record: &CompatibilityRecord,
cache_rel_path: &str,
) -> Option<&'a str> {
let cache_rel_path_variants = cache_rel_path_match_variants(cache_rel_path);
metadata.reviewed_divergences.iter().find_map(|entry| {
(entry.side == record.side
&& entry.path_suffix.as_ref().is_none_or(|suffix| {
cache_rel_path_variants
.iter()
.any(|candidate| candidate.ends_with(suffix))
})
&& entry.path_contains.as_ref().is_none_or(|needle| {
cache_rel_path_variants
.iter()
.any(|candidate| candidate.contains(needle))
})
&& entry.line.is_none_or(|line| line == record.range.line)
&& entry
.end_line
.is_none_or(|end_line| end_line == record.range.end_line)
&& entry
.column
.is_none_or(|column| column == record.range.column)
&& entry
.end_column
.is_none_or(|end_column| end_column == record.range.end_column))
.then_some(entry.reason.as_str())
})
}
fn cache_rel_path_match_variants(cache_rel_path: &str) -> [String; 3] {
[
cache_rel_path.to_owned(),
format!("scripts/{cache_rel_path}"),
format!("corpus/scripts/{cache_rel_path}"),
]
}
fn classify_compatibility_record(
record: &CompatibilityRecord,
cache_rel_path: &str,
corpus_metadata: &HashMap<String, RuleCorpusMetadataDocument>,
) -> (CompatibilityClassification, Option<String>) {
let rule_codes = compatibility_record_rule_codes(record);
if rule_codes.is_empty() {
return (
CompatibilityClassification::MappingIssue,
Some(format!("no Shuck rule maps {}", record.shellcheck_code)),
);
}
if rule_codes.iter().all(|rule_code| {
corpus_metadata
.get(*rule_code)
.is_some_and(|metadata| metadata.review_all_divergences)
}) {
return (
CompatibilityClassification::ReviewedDivergence,
Some(LARGE_CORPUS_ALLOWED_FAILING_RULE_REASON.to_owned()),
);
}
let mut reviewed_reason = None;
for rule_code in rule_codes {
let Some(metadata) = corpus_metadata.get(rule_code) else {
return (CompatibilityClassification::Implementation, None);
};
if let Some(reason) = reviewed_divergence_reason(metadata, record, cache_rel_path) {
reviewed_reason.get_or_insert_with(|| reason.to_owned());
continue;
}
return (CompatibilityClassification::Implementation, None);
}
if let Some(reason) = reviewed_reason {
return (
CompatibilityClassification::ReviewedDivergence,
Some(reason),
);
}
(CompatibilityClassification::Implementation, None)
}
fn shellcheck_compatibility_records(
diagnostics: &[ShellCheckDiagnostic],
shellcheck_rule_index: &HashMap<u32, Vec<String>>,
) -> Vec<CompatibilityRecord> {
diagnostics
.iter()
.map(|diag| {
let rule_codes = shellcheck_rule_index
.get(&diag.code)
.cloned()
.unwrap_or_default();
CompatibilityRecord {
side: CompatibilitySide::ShellcheckOnly,
rule_code: (rule_codes.len() == 1).then(|| rule_codes[0].clone()),
rule_codes,
shellcheck_code: format!("SC{:04}", diag.code),
range: DiagnosticRange {
line: diag.line,
end_line: diag.end_line,
column: diag.column,
end_column: diag.end_column,
},
message: format!("{} {}", diag.level, diag.message),
}
})
.collect()
}
fn shuck_compatibility_records(
diagnostics: &[shuck_linter::Diagnostic],
shellcheck_index: &HashMap<String, String>,
) -> Vec<CompatibilityRecord> {
diagnostics
.iter()
.filter_map(|diag| {
shellcheck_index
.get(diag.code())
.map(|shellcheck_code| CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some(diag.code().to_owned()),
rule_codes: Vec::new(),
shellcheck_code: shellcheck_code.clone(),
range: DiagnosticRange {
line: diag.span.start.line,
end_line: diag.span.end.line,
column: diag.span.start.column,
end_column: diag.span.end.column,
},
message: diag.message.clone(),
})
})
.collect()
}
fn compatibility_record_key(record: &CompatibilityRecord) -> CompatibilityRecordKey {
CompatibilityRecordKey {
shellcheck_code: record.shellcheck_code.clone(),
range: record.range.clone(),
}
}
fn compatibility_record_rule_codes(record: &CompatibilityRecord) -> Vec<&str> {
if !record.rule_codes.is_empty() {
return record.rule_codes.iter().map(String::as_str).collect();
}
record.rule_code.iter().map(String::as_str).collect()
}
fn unmatched_compatibility_records(
shellcheck_records: &[CompatibilityRecord],
shuck_records: &[CompatibilityRecord],
) -> (Vec<CompatibilityRecord>, Vec<CompatibilityRecord>) {
let mut shuck_counts = HashMap::new();
for record in shuck_records {
*shuck_counts
.entry(compatibility_record_key(record))
.or_insert(0usize) += 1;
}
let mut shellcheck_counts = HashMap::new();
for record in shellcheck_records {
*shellcheck_counts
.entry(compatibility_record_key(record))
.or_insert(0usize) += 1;
}
let mut shellcheck_only = Vec::new();
for record in shellcheck_records {
let key = compatibility_record_key(record);
let matched = shuck_counts.get_mut(&key).is_some_and(|count| {
if *count == 0 {
false
} else {
*count -= 1;
true
}
});
if !matched {
shellcheck_only.push(record.clone());
}
}
let mut shuck_only = Vec::new();
for record in shuck_records {
let key = compatibility_record_key(record);
let matched = shellcheck_counts.get_mut(&key).is_some_and(|count| {
if *count == 0 {
false
} else {
*count -= 1;
true
}
});
if !matched {
shuck_only.push(record.clone());
}
}
(shellcheck_only, shuck_only)
}
fn shuck_parse_aborted(error: &str) -> bool {
!error.starts_with("read error:")
}
fn format_compatibility_record(record: &CompatibilityRecord, reason: Option<&str>) -> String {
let reason = reason
.map(|reason| format!(" reason={reason}"))
.unwrap_or_default();
let rule_codes = compatibility_record_rule_codes(record);
let rule_code = if rule_codes.is_empty() {
"(unmapped)".to_owned()
} else {
rule_codes.join(",")
};
format!(
"{} {}/{} {} {}{}",
record.side.as_str(),
rule_code,
record.shellcheck_code,
record.range.display(),
record.message,
reason,
)
}
fn format_large_corpus_failure_report(collection: &FixtureFailureCollection) -> String {
let report =
format_large_corpus_report_with_mode(collection, LargeCorpusReportMode::BlockingOnly);
if !collection.has_nonblocking_items() {
return report;
}
format!(
"{}\n\nNonblocking issue buckets were omitted from the failing log output. See the compatibility summary counts above for skipped unsupported shells, mapping issues, reviewed divergences, and harness warnings.",
report
)
}
fn format_large_corpus_report(collection: &FixtureFailureCollection) -> String {
format_large_corpus_report_with_mode(collection, LargeCorpusReportMode::Full)
}
fn timeout_cap_note_message() -> String {
format!(
"only the first {} fixture timeouts were recorded as harness warnings; additional timeout fixtures were omitted",
LARGE_CORPUS_TIMEOUT_FAILURE_CAP
)
}
fn timeout_cap_note_suffix(timeout_cap_reached: bool) -> String {
if timeout_cap_reached {
format!("; {}", timeout_cap_note_message())
} else {
String::new()
}
}
fn timeout_cap_note_line(scope: &str, timeout_cap_reached: bool) -> Option<String> {
timeout_cap_reached.then(|| format!("{scope} note: {}.", timeout_cap_note_message()))
}
fn emit_timeout_cap_note(scope: &str, timeout_cap_reached: bool) {
if let Some(note) = timeout_cap_note_line(scope, timeout_cap_reached) {
eprintln!("{note}");
}
}
fn format_large_corpus_report_with_mode(
collection: &FixtureFailureCollection,
mode: LargeCorpusReportMode,
) -> String {
let mut sections = Vec::new();
if let Some(section) =
format_report_section("Implementation Diffs", &collection.implementation_diffs)
{
sections.push(section);
}
if matches!(mode, LargeCorpusReportMode::Full) {
if let Some(section) = format_report_section("Mapping Issues", &collection.mapping_issues) {
sections.push(section);
}
if let Some(section) =
format_report_section("Reviewed Divergence", &collection.reviewed_divergences)
{
sections.push(section);
}
if let Some(section) =
format_report_section("Harness Warnings", &collection.harness_warnings)
{
sections.push(section);
}
}
if let Some(section) = format_report_section("Harness Failures", &collection.harness_failures) {
sections.push(section);
}
if sections.is_empty() {
"(none)".into()
} else {
sections.join("\n\n")
}
}
fn format_report_section(title: &str, items: &[String]) -> Option<String> {
if items.is_empty() {
None
} else {
Some(format!("{}:\n{}", title, items.join("\n\n")))
}
}
fn format_fixture_failure(path: &Path, issues: &[String]) -> String {
format!(
"{}\n{}",
path.display(),
indent_detail(&issues.join("\n\n"))
)
}
fn fixture_supported_for_large_corpus(
fixture: &LargeCorpusFixture,
shellcheck_supported_shells: Option<&HashMap<&'static str, ()>>,
) -> bool {
if path_is_statically_ignored_large_corpus_fixture(&fixture.path)
|| path_is_sample_file(&fixture.path)
|| path_is_fish_file(&fixture.path)
|| path_is_patch_file(&fixture.path)
|| path_is_guess_file(&fixture.path)
|| path_is_config_sub_file(&fixture.path)
|| fixture_is_repo_git_entry(fixture)
{
return false;
}
shell_supported_for_large_corpus(
effective_large_corpus_shell(fixture),
shellcheck_supported_shells,
)
}
fn fixture_selected_for_large_corpus_zsh_parse(fixture: &LargeCorpusFixture) -> bool {
if path_is_statically_ignored_large_corpus_fixture(&fixture.path)
|| path_is_sample_file(&fixture.path)
|| path_is_fish_file(&fixture.path)
|| path_is_patch_file(&fixture.path)
|| path_is_guess_file(&fixture.path)
|| path_is_config_sub_file(&fixture.path)
|| fixture_is_repo_git_entry(fixture)
{
return false;
}
effective_large_corpus_shell(fixture) == "zsh"
}
fn shell_supported_for_large_corpus(
shell: &str,
shellcheck_supported_shells: Option<&HashMap<&'static str, ()>>,
) -> bool {
if shell == "zsh" {
return false;
}
shellcheck_supported_shells
.map(|supported| supported.contains_key(shell))
.unwrap_or_else(|| default_supported_large_corpus_shell(shell))
}
fn default_supported_large_corpus_shell(shell: &str) -> bool {
matches!(shell, "sh" | "bash" | "dash" | "ksh")
}
fn effective_large_corpus_shell(fixture: &LargeCorpusFixture) -> &str {
if fixture_looks_like_zsh(fixture) {
"zsh"
} else {
fixture.shell.as_str()
}
}
fn fixture_looks_like_zsh(fixture: &LargeCorpusFixture) -> bool {
if path_has_large_corpus_static_zsh_override(&fixture.path) {
return true;
}
let Some(name) = fixture.path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.ends_with(".zsh") || name.ends_with(".zsh-theme") || name.starts_with(".zsh")
}
fn path_matches_large_corpus_suffix(path: &Path, suffixes: &[&str]) -> bool {
let path = path.to_string_lossy();
suffixes.iter().any(|suffix| path.ends_with(suffix))
}
fn path_is_statically_ignored_large_corpus_fixture(path: &Path) -> bool {
path_matches_large_corpus_suffix(path, LARGE_CORPUS_STATIC_IGNORE_SUFFIXES)
}
fn path_has_large_corpus_static_zsh_override(path: &Path) -> bool {
path_matches_large_corpus_suffix(path, LARGE_CORPUS_STATIC_ZSH_OVERRIDE_SUFFIXES)
}
fn path_has_reviewed_shellcheck_parse_abort(path: &Path) -> bool {
path_matches_large_corpus_suffix(path, LARGE_CORPUS_REVIEWED_SHELLCHECK_PARSE_ABORT_SUFFIXES)
}
fn path_has_reviewed_shuck_parse_exception(path: &Path) -> bool {
path_matches_large_corpus_suffix(path, LARGE_CORPUS_REVIEWED_SHUCK_PARSE_EXCEPTION_SUFFIXES)
}
fn reviewed_large_corpus_harness_warning_reason(
path: &Path,
details: &[String],
) -> Option<&'static str> {
let shellcheck_parse_aborted = details
.iter()
.any(|detail| detail.contains("shellcheck parse aborted"));
let shuck_parse_errored = details
.iter()
.any(|detail| detail.contains("shuck parse error:"));
if shellcheck_parse_aborted
&& !shuck_parse_errored
&& path_has_reviewed_shellcheck_parse_abort(path)
{
return Some(
"reviewed fixture exception: shellcheck stops on this fixture even though the target shell still accepts it",
);
}
if details
.iter()
.any(|detail| detail.contains("shellcheck parsed fixture successfully"))
&& path_has_reviewed_shuck_parse_exception(path)
{
return Some(
"reviewed fixture exception: this valid bash completion pattern still trips Shuck's parser",
);
}
None
}
fn record_reviewed_large_corpus_harness_warning(
evaluation: &mut FixtureEvaluation,
path: &Path,
details: &[String],
) -> bool {
let Some(reason) = reviewed_large_corpus_harness_warning_reason(path, details) else {
return false;
};
let mut warning_details = vec![reason.to_owned()];
warning_details.extend(details.iter().cloned());
evaluation.harness_failure = Some(FixtureFailure {
kind: FixtureFailureKind::Warning,
message: format_fixture_failure(path, &warning_details),
});
true
}
fn path_is_sample_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.ends_with(".sample"))
}
fn path_is_patch_file(path: &Path) -> bool {
let path = path.to_string_lossy().to_lowercase();
path.ends_with(".patch") || path.ends_with(".diff") || path.ends_with(".dpatch")
}
fn path_is_fish_file(path: &Path) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| ext.eq_ignore_ascii_case("fish"))
}
fn path_is_appledouble_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.starts_with("._"))
}
fn path_is_guess_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.to_ascii_lowercase().ends_with(".guess"))
}
fn path_is_config_sub_file(path: &Path) -> bool {
path.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.to_ascii_lowercase().ends_with("config.sub"))
}
fn fixture_is_repo_git_entry(fixture: &LargeCorpusFixture) -> bool {
let Some(name) = fixture.path.file_name().and_then(|name| name.to_str()) else {
return false;
};
name.contains("__.git__")
}
fn resolve_large_corpus_config() -> Option<LargeCorpusConfig> {
if !env_truthy(LARGE_CORPUS_ENV, false) {
return None;
}
let repo_root = repo_root();
let default_root = repo_root.join(LARGE_CORPUS_CACHE_DIR_NAME);
let root_hint = env::var(LARGE_CORPUS_ROOT_ENV)
.ok()
.filter(|s| !s.is_empty());
let candidates: Vec<PathBuf> = if let Some(ref hint) = root_hint {
vec![PathBuf::from(hint)]
} else {
vec![
default_root.clone(),
repo_root.join("..").join("shell-checks"),
]
};
for candidate in &candidates {
if let Some(corpus_dir) = normalize_large_corpus_root(candidate) {
let timeout_secs = positive_env_int(
LARGE_CORPUS_SHELLCHECK_TIMEOUT_ENV,
LARGE_CORPUS_DEFAULT_SHELLCHECK_TIMEOUT.as_secs() as usize,
);
let shuck_timeout_secs = positive_env_int(
LARGE_CORPUS_SHUCK_TIMEOUT_ENV,
LARGE_CORPUS_DEFAULT_SHUCK_TIMEOUT.as_secs() as usize,
);
let total_shards = positive_env_int(LARGE_CORPUS_SHARDS_ENV, 1);
let shard_index = non_negative_env_int(LARGE_CORPUS_SHARD_ENV, 0);
let selected_rules = parse_large_corpus_rule_filter_env(LARGE_CORPUS_RULES_ENV);
let sample_percent = percentage_env_int(LARGE_CORPUS_SAMPLE_PERCENT_ENV, 100);
assert!(
shard_index < total_shards,
"{LARGE_CORPUS_SHARD_ENV}={shard_index}, want value in [0,{total_shards})"
);
return Some(LargeCorpusConfig {
corpus_dir,
cache_dir: default_root,
shellcheck_timeout: Duration::from_secs(timeout_secs as u64),
shuck_timeout: Duration::from_secs(shuck_timeout_secs as u64),
shard_index,
total_shards,
selected_rules,
sample_percent,
mapped_only: env_truthy(LARGE_CORPUS_MAPPED_ONLY_ENV, false),
keep_going: env_truthy(LARGE_CORPUS_KEEP_GOING_ENV, false),
timing_mode: env_truthy(LARGE_CORPUS_TIMING_ENV, false),
});
}
}
panic!(
"large corpus not found; set {LARGE_CORPUS_ROOT_ENV} to an existing corpus directory, \
run scripts/corpus-download.sh to populate {}, or place shell-checks at {}",
default_root.display(),
repo_root.join("..").join("shell-checks").display(),
);
}
fn normalize_large_corpus_root(root: &Path) -> Option<PathBuf> {
if !root.is_dir() {
return None;
}
let repo_corpus = root.join("corpus");
if corpus_dir_looks_valid(&repo_corpus) {
return Some(repo_corpus);
}
if corpus_dir_looks_valid(root) {
return Some(root.to_path_buf());
}
None
}
fn corpus_dir_looks_valid(dir: &Path) -> bool {
dir.join("scripts").is_dir() && dir.join("manifest.yaml").is_file()
}
fn repo_root() -> PathBuf {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
manifest_dir
.parent()
.and_then(|p| p.parent())
.expect("failed to resolve repo root")
.to_path_buf()
}
fn discover_worktree_roots() -> Vec<PathBuf> {
let repo_root = repo_root();
let output = Command::new("git")
.current_dir(&repo_root)
.args(["worktree", "list", "--porcelain"])
.output();
let mut roots = Vec::new();
let mut seen = HashSet::new();
if let Ok(output) = output
&& output.status.success()
{
let stdout = String::from_utf8_lossy(&output.stdout);
for line in stdout.lines() {
let Some(path) = line.strip_prefix("worktree ") else {
continue;
};
let root = PathBuf::from(path);
if seen.insert(root.clone()) {
roots.push(root);
}
}
}
if seen.insert(repo_root.clone()) {
roots.push(repo_root);
}
roots
}
fn normalize_cache_rel_path(path: &Path) -> String {
path.to_string_lossy().replace('\\', "/")
}
fn projected_worktree_fixture_paths(
cache_rel_path: &Path,
worktree_roots: &[PathBuf],
) -> Vec<PathBuf> {
let mut absolute_paths = Vec::new();
let mut seen = HashSet::new();
for root in worktree_roots {
let corpus_base = root.join(".cache").join("large-corpus");
for projected in [
corpus_base.join("scripts").join(cache_rel_path),
corpus_base
.join("corpus")
.join("scripts")
.join(cache_rel_path),
] {
if seen.insert(projected.clone()) {
absolute_paths.push(projected);
}
}
}
absolute_paths
}
fn shellcheck_cache_migration_fingerprint(fixtures: &[LargeCorpusFixture]) -> String {
let mut hasher = Sha256::new();
update_hash_component(&mut hasher, &SHELLCHECK_CACHE_MIGRATION_VERSION.to_string());
for fixture in fixtures {
update_hash_component(&mut hasher, &fixture.cache_rel_path_key());
update_hash_component(&mut hasher, &fixture.shell);
update_hash_component(&mut hasher, &fixture.source_hash);
}
let result = hasher.finalize();
result.iter().map(|byte| format!("{byte:02x}")).collect()
}
fn update_hash_component(hasher: &mut Sha256, value: &str) {
hasher.update((value.len() as u64).to_le_bytes());
hasher.update(value.as_bytes());
}
fn load_fixtures(cfg: &LargeCorpusConfig) -> Vec<LargeCorpusFixture> {
let mut fixtures = collect_fixtures(&cfg.corpus_dir);
fixtures = shard_fixtures(fixtures, cfg.shard_index, cfg.total_shards);
fixtures = sample_fixtures(fixtures, cfg.sample_percent);
fixtures
}
fn collect_fixtures(corpus_dir: &Path) -> Vec<LargeCorpusFixture> {
let scripts_dir = corpus_dir.join("scripts");
let mut fixtures = Vec::new();
for entry in walkdir::WalkDir::new(&scripts_dir)
.into_iter()
.filter_entry(|e| {
let name = e.file_name().to_string_lossy();
name != ".shuck_cache" && name != ".shellck_cache"
})
{
let entry = match entry {
Ok(e) => e,
Err(_) => continue,
};
if !entry.file_type().is_file() {
continue;
}
let path = entry.path().to_path_buf();
if path_is_statically_ignored_large_corpus_fixture(&path)
|| path_is_sample_file(&path)
|| path_is_fish_file(&path)
|| path_is_patch_file(&path)
|| path_is_appledouble_file(&path)
|| path_is_guess_file(&path)
|| path_is_config_sub_file(&path)
{
continue;
}
let cache_rel_path = path
.strip_prefix(&scripts_dir)
.unwrap_or(path.as_path())
.to_path_buf();
let src = match fs::read(&path) {
Ok(s) => s,
Err(_) => continue,
};
let shell = resolve_shell(&path, &src);
let source_hash = hash_bytes(&src);
fixtures.push(LargeCorpusFixture {
path,
cache_rel_path,
shell,
source_hash,
});
}
fixtures.sort_by(|a, b| a.path.cmp(&b.path));
fixtures
}
fn shard_fixtures(
fixtures: Vec<LargeCorpusFixture>,
shard_index: usize,
total_shards: usize,
) -> Vec<LargeCorpusFixture> {
if fixtures.is_empty() || total_shards <= 1 {
return fixtures;
}
let start = shard_index * fixtures.len() / total_shards;
let end = (shard_index + 1) * fixtures.len() / total_shards;
fixtures[start..end].to_vec()
}
fn sample_fixtures(
fixtures: Vec<LargeCorpusFixture>,
sample_percent: usize,
) -> Vec<LargeCorpusFixture> {
if fixtures.is_empty() || sample_percent >= 100 {
return fixtures;
}
fixtures
.into_iter()
.filter(|fixture| fixture_selected_for_sample(fixture, sample_percent))
.collect()
}
fn fixture_selected_for_sample(fixture: &LargeCorpusFixture, sample_percent: usize) -> bool {
if sample_percent >= 100 {
return true;
}
let mut hasher = Sha256::new();
update_hash_component(&mut hasher, "large-corpus-sample-v1");
update_hash_component(&mut hasher, &fixture.cache_rel_path_key());
let digest = hasher.finalize();
let sample_value = u64::from_be_bytes(digest[..8].try_into().expect("slice has 8 bytes"));
let threshold = ((u64::MAX as u128) + 1) * sample_percent as u128 / 100;
(sample_value as u128) < threshold
}
fn resolve_shell(path: &Path, src: &[u8]) -> String {
let source = String::from_utf8_lossy(src);
let source = source.strip_prefix('\u{feff}').unwrap_or(source.as_ref());
let trimmed_first_line = source
.lines()
.next()
.map(|line| line.trim_start().to_ascii_lowercase())
.unwrap_or_default();
if trimmed_first_line.starts_with("#compdef") || trimmed_first_line.starts_with("#autoload") {
return "zsh".into();
}
if path_has_large_corpus_static_zsh_override(path) {
return "zsh".into();
}
match shuck_linter::ShellDialect::infer(source, Some(path)) {
shuck_linter::ShellDialect::Bash => "bash".into(),
shuck_linter::ShellDialect::Ksh | shuck_linter::ShellDialect::Mksh => "ksh".into(),
shuck_linter::ShellDialect::Zsh => "zsh".into(),
shuck_linter::ShellDialect::Sh | shuck_linter::ShellDialect::Dash => "sh".into(),
shuck_linter::ShellDialect::Unknown => {
unsupported_large_corpus_shebang_shell(&trimmed_first_line)
.map(|shell| shell.to_ascii_lowercase())
.unwrap_or_else(|| "sh".into())
}
}
}
fn unsupported_large_corpus_shebang_shell(first_line: &str) -> Option<&str> {
let line = first_line.strip_prefix("#!")?.trim();
let mut parts = line.split_whitespace();
let first = parts.next()?;
let interpreter = if Path::new(first).file_name()?.to_str()? == "env" {
parts.find(|part| !part.starts_with('-'))?
} else {
Path::new(first).file_name()?.to_str()?
};
match interpreter.to_ascii_lowercase().as_str() {
"fish" | "csh" | "tcsh" => Some(interpreter),
_ => None,
}
}
fn parser_dialect_for_large_corpus_shell(shell: &str) -> shuck_parser::ShellDialect {
match shuck_linter::ShellDialect::from_name(shell) {
shuck_linter::ShellDialect::Sh
| shuck_linter::ShellDialect::Dash
| shuck_linter::ShellDialect::Ksh => shuck_parser::ShellDialect::Posix,
shuck_linter::ShellDialect::Mksh => shuck_parser::ShellDialect::Mksh,
shuck_linter::ShellDialect::Zsh => shuck_parser::ShellDialect::Zsh,
shuck_linter::ShellDialect::Unknown | shuck_linter::ShellDialect::Bash => {
shuck_parser::ShellDialect::Bash
}
}
}
fn shell_profile_for_large_corpus_shell(shell: &str) -> shuck_parser::ShellProfile {
shuck_parser::ShellProfile::native(parser_dialect_for_large_corpus_shell(shell))
}
fn run_shuck_with_parse_dialect(
fixture: &LargeCorpusFixture,
linter_settings: &shuck_linter::LinterSettings,
source_path_resolver: Option<&(dyn shuck_semantic::SourcePathResolver + Send + Sync)>,
parse_dialect: shuck_parser::ShellDialect,
shell: &str,
) -> ShuckRun {
let source = match fs::read_to_string(&fixture.path) {
Ok(s) => s,
Err(e) => {
return ShuckRun {
diagnostics: Vec::new(),
parse_error: Some(format!("read error: {e}")),
};
}
};
let linter_settings = linter_settings
.clone()
.with_shell(shuck_linter::ShellDialect::from_name(shell))
.with_analyzed_paths([fixture.path.clone()]);
let parsed = shuck_parser::parser::Parser::with_dialect(&source, parse_dialect).parse();
let diagnostics = lint_large_corpus_output(
fixture,
&source,
&parsed,
&linter_settings,
source_path_resolver,
);
if parsed.is_err() && diagnostics.is_empty() {
return ShuckRun {
diagnostics: Vec::new(),
parse_error: Some(parsed.strict_error().to_string()),
};
}
ShuckRun {
diagnostics,
parse_error: None,
}
}
fn lint_large_corpus_output(
fixture: &LargeCorpusFixture,
source: &str,
parse_result: &shuck_parser::parser::ParseResult,
linter_settings: &shuck_linter::LinterSettings,
source_path_resolver: Option<&(dyn shuck_semantic::SourcePathResolver + Send + Sync)>,
) -> Vec<shuck_linter::Diagnostic> {
let indexer = shuck_indexer::Indexer::new(source, parse_result);
let shellcheck_map = shuck_linter::ShellCheckCodeMap::default();
let directives = shuck_linter::parse_directives(
source,
&parse_result.file,
indexer.comment_index(),
&shellcheck_map,
);
let suppression_index = (!directives.is_empty()).then(|| {
shuck_linter::SuppressionIndex::new(
&directives,
&parse_result.file,
shuck_linter::first_statement_line(&parse_result.file).unwrap_or(u32::MAX),
)
});
shuck_linter::lint_file_at_path_with_resolver_and_parse_result(
parse_result,
source,
&indexer,
linter_settings,
suppression_index.as_ref(),
Some(&fixture.path),
source_path_resolver,
)
}
fn run_shuck(
fixture: &LargeCorpusFixture,
linter_settings: &shuck_linter::LinterSettings,
source_path_resolver: Option<&(dyn shuck_semantic::SourcePathResolver + Send + Sync)>,
) -> ShuckRun {
run_shuck_with_parse_dialect(
fixture,
linter_settings,
source_path_resolver,
shuck_parser::ShellDialect::Bash,
&fixture.shell,
)
}
fn run_shuck_with_timeout(
fixture: &LargeCorpusFixture,
linter_settings: &shuck_linter::LinterSettings,
timeout: Duration,
source_path_resolver: Arc<LargeCorpusPathResolver>,
) -> Result<ShuckRun, String> {
let fixture = fixture.clone();
let linter_settings = linter_settings.clone();
let source_path_resolver = Arc::clone(&source_path_resolver);
run_with_timeout("shuck", timeout, move || {
run_shuck(
&fixture,
&linter_settings,
Some(source_path_resolver.as_ref()
as &(dyn shuck_semantic::SourcePathResolver + Send + Sync)),
)
})
}
fn parse_fixture_for_effective_large_corpus_shell_with_timeout(
fixture: &LargeCorpusFixture,
timeout: Duration,
) -> Result<Result<(), String>, String> {
let fixture = fixture.clone();
let source = match fs::read_to_string(&fixture.path) {
Ok(source) => source,
Err(err) => return Ok(Err(format!("read error: {err}"))),
};
let timeout = effective_shuck_timeout(source.as_bytes(), timeout);
run_with_timeout("shuck", timeout, move || {
let shell = effective_large_corpus_shell(&fixture);
let shell_profile = shell_profile_for_large_corpus_shell(shell);
let parsed =
shuck_parser::parser::Parser::with_profile(&source, shell_profile.clone()).parse();
if parsed.is_err() {
return Err(parsed.strict_error().to_string());
}
if shell == "zsh" {
extract_large_corpus_zsh_option_state(&fixture.path, &source, &parsed, shell_profile)?;
}
Ok(())
})
}
fn extract_large_corpus_zsh_option_state(
path: &Path,
source: &str,
output: &shuck_parser::parser::ParseResult,
shell_profile: shuck_parser::ShellProfile,
) -> Result<(), String> {
let indexer = shuck_indexer::Indexer::new(source, output);
let semantic = shuck_semantic::SemanticModel::build_with_options(
&output.file,
source,
&indexer,
shuck_semantic::SemanticBuildOptions {
source_path: Some(path),
shell_profile: Some(shell_profile),
..shuck_semantic::SemanticBuildOptions::default()
},
);
if semantic.shell_profile().zsh_options().is_none() {
return Err("semantic model did not retain a zsh shell profile".to_owned());
}
for stmt in output.file.body.iter() {
if semantic.zsh_options_at(stmt.span.start.offset).is_none() {
return Err(format!(
"missing zsh option snapshot at {}:{}",
stmt.span.start.line, stmt.span.start.column
));
}
}
Ok(())
}
fn build_rule_to_shellcheck_index(
selected_rules: Option<&shuck_linter::RuleSet>,
) -> HashMap<String, String> {
let shellcheck_map = shuck_linter::ShellCheckCodeMap::default();
if let Some(selected_rules) = selected_rules {
return selected_rules
.iter()
.filter_map(|rule| {
shellcheck_map
.code_for_rule(rule)
.map(|sc_code| (rule.code().to_owned(), format!("SC{sc_code}")))
})
.collect();
}
shellcheck_map
.mappings()
.map(|(sc_code, rule)| (rule.code().to_owned(), format!("SC{sc_code}")))
.collect::<HashMap<_, _>>()
}
fn build_shellcheck_to_rule_index(
selected_rules: Option<&shuck_linter::RuleSet>,
) -> HashMap<u32, Vec<String>> {
let shellcheck_map = shuck_linter::ShellCheckCodeMap::default();
if let Some(selected_rules) = selected_rules {
let mut index = HashMap::<u32, Vec<String>>::new();
for rule in selected_rules.iter() {
if let Some(sc_code) = shellcheck_map.code_for_rule(rule) {
index
.entry(sc_code)
.or_default()
.push(rule.code().to_owned());
}
}
for rule_codes in index.values_mut() {
rule_codes.sort();
rule_codes.dedup();
}
return index;
}
let mut index = HashMap::<u32, Vec<String>>::new();
for (sc_code, rule) in shellcheck_map.mappings() {
index
.entry(sc_code)
.or_default()
.push(rule.code().to_owned());
}
for rule_codes in index.values_mut() {
rule_codes.sort();
rule_codes.dedup();
}
index
}
fn build_large_corpus_linter_settings(
selected_rules: Option<shuck_linter::RuleSet>,
mapped_only: bool,
) -> shuck_linter::LinterSettings {
let settings = if let Some(rules) = selected_rules {
shuck_linter::LinterSettings::for_rules(rules.iter())
} else if mapped_only {
let mapped_rules: HashSet<_> = shuck_linter::ShellCheckCodeMap::default()
.mappings()
.map(|(_, rule)| rule)
.collect();
shuck_linter::LinterSettings::for_rules(mapped_rules)
} else {
shuck_linter::LinterSettings::default()
};
settings.with_c001_treat_indirect_expansion_targets_as_used(false)
}
fn large_corpus_uses_c001_shellcheck_compat(
linter_settings: &shuck_linter::LinterSettings,
) -> bool {
linter_settings
.rules
.contains(shuck_linter::Rule::UnusedAssignment)
&& !linter_settings
.rule_options
.c001
.treat_indirect_expansion_targets_as_used
}
fn large_corpus_c001_compat_note_line(
linter_settings: &shuck_linter::LinterSettings,
) -> Option<&'static str> {
large_corpus_uses_c001_shellcheck_compat(linter_settings)
.then_some(LARGE_CORPUS_C001_COMPAT_NOTE)
}
fn emit_large_corpus_c001_compat_note(linter_settings: &shuck_linter::LinterSettings) {
if let Some(note) = large_corpus_c001_compat_note_line(linter_settings) {
eprintln!("{note}");
}
}
fn build_shellcheck_filter_codes(
selected_rules: Option<shuck_linter::RuleSet>,
mapped_only: bool,
) -> Option<HashSet<u32>> {
selected_rules
.map(|rules| {
validate_selected_rules_for_large_corpus(&rules)
.unwrap_or_else(|err| panic!("{LARGE_CORPUS_RULES_ENV}, {err}"));
build_selected_shellcheck_codes(&rules)
})
.or_else(|| mapped_only.then(build_mapped_shellcheck_codes))
}
fn build_mapped_shellcheck_codes() -> HashSet<u32> {
shuck_linter::ShellCheckCodeMap::default()
.mappings()
.map(|(sc_code, _)| sc_code)
.collect()
}
fn build_selected_shellcheck_codes(selected_rules: &shuck_linter::RuleSet) -> HashSet<u32> {
let shellcheck_map = shuck_linter::ShellCheckCodeMap::default();
selected_rules
.iter()
.filter_map(|rule| shellcheck_map.code_for_rule(rule))
.collect()
}
fn validate_selected_rules_for_large_corpus(
selected_rules: &shuck_linter::RuleSet,
) -> Result<(), String> {
let _ = selected_rules;
Ok(())
}
fn legacy_shellcheck_invocation_hash(shellcheck_path: &str) -> String {
let meta = fs::metadata(shellcheck_path).ok();
let size = meta.as_ref().map(|m| m.len()).unwrap_or(0);
let key = format!("{}:{}:shellcheck", shellcheck_path, size);
hash_bytes(key.as_bytes())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DiffMode {
All,
Overreports,
}
fn count_codes(codes: &[String]) -> HashMap<String, usize> {
let mut counts = HashMap::new();
for code in codes {
*counts.entry(code.clone()).or_insert(0) += 1;
}
counts
}
fn compatibility_code_diff(
want: &HashMap<String, usize>,
got: &HashMap<String, usize>,
mode: DiffMode,
) -> Option<String> {
let mut keys: Vec<&String> = want.keys().chain(got.keys()).collect();
keys.sort();
keys.dedup();
let mut lines = Vec::new();
for key in &keys {
let w = want.get(*key).copied().unwrap_or(0);
let g = got.get(*key).copied().unwrap_or(0);
if w == g {
continue;
}
if mode == DiffMode::Overreports && g <= w {
continue;
}
lines.push(format!("{key}: shellcheck={w} shuck={g}"));
}
if lines.is_empty() {
None
} else {
Some(lines.join("\n"))
}
}
fn format_range(start_line: usize, start_col: usize, end_line: usize, end_col: usize) -> String {
let el = if end_line == 0 { start_line } else { end_line };
let ec = if end_col == 0 { start_col } else { end_col };
format!("{start_line}:{start_col}-{el}:{ec}")
}
fn indent_detail(detail: &str) -> String {
detail
.lines()
.map(|line| format!(" {line}"))
.collect::<Vec<_>>()
.join("\n")
}
fn hash_bytes(data: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(data);
let result = hasher.finalize();
result.iter().map(|b| format!("{b:02x}")).collect()
}
fn env_truthy(key: &str, default: bool) -> bool {
match env::var(key).ok().as_deref() {
None | Some("") => default,
Some(v) => matches!(v.to_lowercase().as_str(), "1" | "true" | "yes" | "on"),
}
}
fn positive_env_int(key: &str, default: usize) -> usize {
match env::var(key).ok().filter(|s| !s.is_empty()) {
None => default,
Some(v) => {
let parsed: usize = v
.parse()
.unwrap_or_else(|_| panic!("{key}={v:?}, want positive integer"));
assert!(parsed > 0, "{key}={v:?}, want positive integer");
parsed
}
}
}
fn parse_large_corpus_rule_filter_env(key: &str) -> Option<shuck_linter::RuleSet> {
let value = env::var(key).ok()?;
let trimmed = value.trim();
if trimmed.is_empty() {
return None;
}
Some(
parse_large_corpus_rule_set(trimmed)
.unwrap_or_else(|err| panic!("{key}={trimmed:?}, {err}")),
)
}
fn parse_large_corpus_rule_set(value: &str) -> Result<shuck_linter::RuleSet, String> {
let selectors: Vec<shuck_linter::RuleSelector> = value
.split(',')
.map(str::trim)
.filter(|selector| !selector.is_empty())
.map(|selector| {
selector
.parse::<shuck_linter::RuleSelector>()
.map_err(|err| err.to_string())
})
.collect::<Result<_, _>>()?;
if selectors.is_empty() {
return Err("want at least one comma-separated rule selector".into());
}
Ok(shuck_linter::LinterSettings::from_selectors(&selectors, &[]).rules)
}
fn percentage_env_int(key: &str, default: usize) -> usize {
match env::var(key).ok().filter(|s| !s.is_empty()) {
None => default,
Some(v) => {
let parsed: usize = v
.parse()
.unwrap_or_else(|_| panic!("{key}={v:?}, want integer percentage in [1,100]"));
assert!(
(1..=100).contains(&parsed),
"{key}={v:?}, want integer percentage in [1,100]"
);
parsed
}
}
}
fn non_negative_env_int(key: &str, default: usize) -> usize {
match env::var(key).ok().filter(|s| !s.is_empty()) {
None => default,
Some(v) => v
.parse()
.unwrap_or_else(|_| panic!("{key}={v:?}, want non-negative integer")),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn resolve_shell_bash_shebang() {
assert_eq!(
resolve_shell(Path::new("script"), b"#!/usr/bin/env bash\n"),
"bash"
);
}
#[test]
fn resolve_shell_dash_maps_to_sh() {
assert_eq!(resolve_shell(Path::new("script"), b"#!/bin/dash\n"), "sh");
}
#[test]
fn resolve_shell_zsh_shebang() {
assert_eq!(resolve_shell(Path::new("script"), b"#!/bin/zsh\n"), "zsh");
}
#[test]
fn resolve_shell_fish_shebang_stays_unsupported() {
assert_eq!(
resolve_shell(Path::new("script"), b"#!/usr/bin/env fish\n"),
"fish"
);
}
#[test]
fn resolve_shell_csh_shebang_stays_unsupported() {
assert_eq!(resolve_shell(Path::new("script"), b"#!/bin/csh\n"), "csh");
}
#[test]
fn resolve_shell_respects_shellcheck_header_over_unsupported_shebang() {
assert_eq!(
resolve_shell(
Path::new("script"),
b"#!/usr/bin/env fish\n# shellcheck shell=bash\necho hi\n",
),
"bash"
);
}
#[test]
fn resolve_shell_compdef_header_is_zsh() {
assert_eq!(resolve_shell(Path::new("_wd.sh"), b"#compdef wd\n"), "zsh");
}
#[test]
fn resolve_shell_static_zsh_override_is_zsh() {
assert_eq!(
resolve_shell(
Path::new("ohmyzsh__ohmyzsh__oh-my-zsh.sh"),
b"# comment only\n",
),
"zsh"
);
}
#[test]
fn resolve_shell_bash_extension_fallback() {
assert_eq!(
resolve_shell(Path::new("example.bash"), b"echo hi\n"),
"bash"
);
}
#[test]
fn resolve_shell_bom_prefixed_bash_shebang_without_extension() {
assert_eq!(
resolve_shell(
Path::new("example"),
b"\xEF\xBB\xBF#!/usr/bin/env bash\necho hi\n",
),
"bash"
);
}
#[test]
fn resolve_shell_ignores_generic_shell_script_comments_for_bash_files() {
assert_eq!(
resolve_shell(
Path::new("example.bash"),
b"# -*- shell-script -*-\nfor ((i = 0; i < 5; i++)); do :; done\n",
),
"bash"
);
}
#[test]
fn resolve_shell_sh_extension_fallback() {
assert_eq!(resolve_shell(Path::new("example.sh"), b"echo hi\n"), "sh");
}
#[test]
fn resolve_shell_default_sh() {
assert_eq!(resolve_shell(Path::new("example"), b"echo hi\n"), "sh");
}
#[test]
fn fixture_progress_label_uses_cache_relative_path() {
let fixture = fixture_at(
Path::new("/tmp/worktree-a/.cache/large-corpus/scripts/nested/example.sh"),
Path::new("nested/example.sh"),
);
assert_eq!(fixture_progress_label(&fixture), "nested/example.sh");
}
#[test]
fn progress_bucket_waits_until_a_full_five_percent_is_complete() {
assert_eq!(progress_bucket(21, 1), 0);
assert_eq!(progress_bucket(21, 2), 1);
}
#[test]
fn progress_bucket_can_skip_ahead_for_small_fixture_sets() {
assert_eq!(progress_bucket(3, 1), 6);
assert_eq!(progress_bucket(3, 2), 13);
assert_eq!(progress_bucket(3, 3), 20);
}
#[test]
fn progress_bucket_clamps_to_one_hundred_percent() {
assert_eq!(progress_bucket(20, 25), 20);
}
#[test]
fn format_fixture_elapsed_uses_millis_for_short_runs() {
assert_eq!(format_fixture_elapsed(Duration::from_millis(842)), "842ms");
}
#[test]
fn format_fixture_elapsed_uses_seconds_for_longer_runs() {
assert_eq!(
format_fixture_elapsed(Duration::from_millis(1_234)),
"1.234s"
);
}
#[test]
fn ranked_large_corpus_timings_sorts_descending_and_truncates_to_limit() {
let records = (0..30)
.map(|i| {
timing_record(
&format!("script-{i:02}.sh"),
(i + 1) as u64,
LargeCorpusTimingOutcome::Ok,
)
})
.collect::<Vec<_>>();
let ranked = ranked_large_corpus_timings(&records);
assert_eq!(ranked.len(), LARGE_CORPUS_TIMING_LIMIT);
assert_eq!(ranked[0].fixture_label, "script-29.sh");
assert_eq!(ranked[0].elapsed, Duration::from_millis(30));
assert_eq!(ranked.last().unwrap().fixture_label, "script-05.sh");
}
#[test]
fn ranked_large_corpus_timings_break_ties_by_fixture_label() {
let ranked = ranked_large_corpus_timings(&[
timing_record("zeta.sh", 50, LargeCorpusTimingOutcome::Ok),
timing_record("alpha.sh", 50, LargeCorpusTimingOutcome::Timeout),
timing_record("mid.sh", 50, LargeCorpusTimingOutcome::Error),
]);
assert_eq!(
ranked
.iter()
.map(|record| record.fixture_label.as_str())
.collect::<Vec<_>>(),
vec!["alpha.sh", "mid.sh", "zeta.sh"]
);
}
#[test]
fn ranked_large_corpus_timings_keep_non_ok_statuses() {
let ranked = ranked_large_corpus_timings(&[
timing_record("timeout.sh", 90, LargeCorpusTimingOutcome::Timeout),
timing_record("error.sh", 80, LargeCorpusTimingOutcome::Error),
timing_record("ok.sh", 70, LargeCorpusTimingOutcome::Ok),
]);
assert_eq!(
ranked
.iter()
.map(|record| record.outcome)
.collect::<Vec<_>>(),
vec![
LargeCorpusTimingOutcome::Timeout,
LargeCorpusTimingOutcome::Error,
LargeCorpusTimingOutcome::Ok,
]
);
}
#[test]
fn format_large_corpus_timing_report_handles_fewer_than_limit() {
let report = format_large_corpus_timing_report(&LargeCorpusTimingCollection {
records: vec![
timing_record("slow.sh", 900, LargeCorpusTimingOutcome::Ok),
timing_record("faster.sh", 300, LargeCorpusTimingOutcome::Ok),
],
timeout_cap_reached: false,
});
assert!(report.contains("showing 2 slowest shuck fixture(s) out of 2 measured fixture(s)"));
assert!(report.contains("1. 900ms [ok] slow.sh"));
assert!(report.contains("2. 300ms [ok] faster.sh"));
}
#[test]
fn format_large_corpus_timing_report_includes_status_labels() {
let report = format_large_corpus_timing_report(&LargeCorpusTimingCollection {
records: vec![
timing_record("ok.sh", 40, LargeCorpusTimingOutcome::Ok),
timing_record("parse.sh", 30, LargeCorpusTimingOutcome::ParseError),
timing_record("timeout.sh", 20, LargeCorpusTimingOutcome::Timeout),
timing_record("error.sh", 10, LargeCorpusTimingOutcome::Error),
],
timeout_cap_reached: false,
});
assert!(report.contains("[ok] ok.sh"));
assert!(report.contains("[parse-error] parse.sh"));
assert!(report.contains("[timeout] timeout.sh"));
assert!(report.contains("[error] error.sh"));
}
#[test]
fn format_large_corpus_timing_report_handles_empty_input() {
assert_eq!(
format_large_corpus_timing_report(&LargeCorpusTimingCollection::default()),
"large corpus timing: no supported fixtures selected"
);
}
#[test]
fn collect_fixture_timing_records_stops_after_timeout_cap() {
let fixtures = (0..10)
.map(|i| fixture(&format!("timeout-{i}.sh")))
.collect::<Vec<_>>();
let fixture_refs = fixtures.iter().collect::<Vec<_>>();
let seen = AtomicUsize::new(0);
let collection = collect_fixture_timing_records(&fixture_refs, |fixture| {
seen.fetch_add(1, Ordering::Relaxed);
timing_record(
fixture.path.to_string_lossy().as_ref(),
100,
LargeCorpusTimingOutcome::Timeout,
)
});
assert!(collection.timeout_cap_reached);
assert_eq!(collection.records.len(), LARGE_CORPUS_TIMEOUT_FAILURE_CAP);
assert_eq!(
seen.load(Ordering::Relaxed),
LARGE_CORPUS_TIMEOUT_FAILURE_CAP
);
}
#[test]
fn format_large_corpus_timing_report_includes_timeout_cap_note() {
let report = format_large_corpus_timing_report(&LargeCorpusTimingCollection {
records: vec![timing_record(
"timeout.sh",
100,
LargeCorpusTimingOutcome::Timeout,
)],
timeout_cap_reached: true,
});
assert!(report.contains("large corpus timing note: stopped after"));
}
#[test]
fn run_with_timeout_returns_completed_result() {
let result = run_with_timeout("test worker", Duration::from_millis(50), || 42).unwrap();
assert_eq!(result, 42);
}
#[test]
fn run_with_timeout_reports_timeout() {
let err = run_with_timeout("test worker", Duration::from_millis(10), || {
thread::sleep(Duration::from_secs(5));
42
})
.unwrap_err();
assert_eq!(err, "test worker timed out after 10ms");
}
#[cfg(unix)]
#[test]
fn run_shellcheck_reports_timeout() {
use std::os::unix::fs::PermissionsExt;
let tempdir = tempfile::tempdir().unwrap();
let fixture_path = tempdir.path().join("fixture.sh");
let shellcheck_path = tempdir.path().join("fake-shellcheck");
fs::write(&fixture_path, "echo hi\n").unwrap();
fs::write(&shellcheck_path, "#!/bin/sh\nsleep 1\nprintf '[]'\n").unwrap();
let mut permissions = fs::metadata(&shellcheck_path).unwrap().permissions();
permissions.set_mode(0o755);
fs::set_permissions(&shellcheck_path, permissions).unwrap();
let err = run_shellcheck(
&fixture_path,
"sh",
shellcheck_path.to_str().unwrap(),
Duration::from_millis(10),
)
.unwrap_err();
assert_eq!(err, "shellcheck timed out after 10ms");
}
#[test]
fn shard_fixtures_contiguous_split() {
let fixtures: Vec<LargeCorpusFixture> = (0..100)
.map(|i| LargeCorpusFixture {
path: PathBuf::from(format!("script-{i:03}.sh")),
cache_rel_path: PathBuf::from(format!("script-{i:03}.sh")),
shell: "sh".into(),
source_hash: String::new(),
})
.collect();
let shard0 = shard_fixtures(fixtures.clone(), 0, 4);
assert_eq!(shard0.len(), 25);
assert_eq!(shard0[0].path, PathBuf::from("script-000.sh"));
let shard1 = shard_fixtures(fixtures.clone(), 1, 4);
assert_eq!(shard1.len(), 25);
assert_eq!(shard1[0].path, PathBuf::from("script-025.sh"));
let shard3 = shard_fixtures(fixtures, 3, 4);
assert_eq!(shard3.len(), 25);
assert_eq!(shard3[0].path, PathBuf::from("script-075.sh"));
}
#[test]
fn sample_fixtures_full_percentage_keeps_everything() {
let fixtures: Vec<LargeCorpusFixture> = (0..10)
.map(|i| fixture(&format!("script-{i:03}.sh")))
.collect();
let sampled = sample_fixtures(fixtures.clone(), 100);
assert_eq!(sampled.len(), fixtures.len());
assert_eq!(
sampled
.iter()
.map(|fixture| &fixture.path)
.collect::<Vec<_>>(),
fixtures
.iter()
.map(|fixture| &fixture.path)
.collect::<Vec<_>>()
);
}
#[test]
fn sample_fixtures_uses_cache_relative_path_for_stability() {
let left = fixture_at(
Path::new("/tmp/worktree-a/.cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let right = fixture_at(
Path::new("/tmp/worktree-b/.cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
assert_eq!(
fixture_selected_for_sample(&left, 17),
fixture_selected_for_sample(&right, 17)
);
}
#[test]
fn sample_fixtures_membership_is_order_independent() {
let fixtures: Vec<LargeCorpusFixture> = (0..200)
.map(|i| fixture(&format!("script-{i:03}.sh")))
.collect();
let mut reversed = fixtures.clone();
reversed.reverse();
let mut forward = sample_fixtures(fixtures, 10)
.into_iter()
.map(|fixture| fixture.cache_rel_path)
.collect::<Vec<_>>();
let mut backward = sample_fixtures(reversed, 10)
.into_iter()
.map(|fixture| fixture.cache_rel_path)
.collect::<Vec<_>>();
forward.sort();
backward.sort();
assert_eq!(forward, backward);
}
#[test]
fn parse_shellcheck_supported_shells_parses_help() {
let help = "Usage: shellcheck [OPTIONS...] FILES...\n \
-s SHELLNAME --shell=SHELLNAME \
Specify dialect (sh, bash, dash, ksh, busybox)\n";
let supported = parse_shellcheck_supported_shells(help);
for shell in &["sh", "bash", "dash", "ksh", "busybox"] {
assert!(
supported.contains_key(shell),
"expected {shell} in supported shells"
);
}
assert!(
!supported.contains_key("zsh"),
"zsh should not be in supported shells"
);
}
#[test]
fn parser_dialect_for_large_corpus_zsh_shell_is_zsh() {
assert_eq!(
parser_dialect_for_large_corpus_shell("zsh"),
shuck_parser::ShellDialect::Zsh
);
}
#[test]
fn zsh_is_not_supported_for_large_corpus_even_if_shellcheck_supports_it() {
let supported = HashMap::from([("sh", ()), ("bash", ()), ("zsh", ())]);
assert!(!shell_supported_for_large_corpus("zsh", Some(&supported)));
assert!(shell_supported_for_large_corpus("sh", Some(&supported)));
assert!(shell_supported_for_large_corpus("bash", Some(&supported)));
}
#[test]
fn unsupported_shebang_shells_are_skipped_without_shellcheck_metadata() {
assert!(!shell_supported_for_large_corpus("fish", None));
assert!(!shell_supported_for_large_corpus("csh", None));
assert!(shell_supported_for_large_corpus("sh", None));
}
#[test]
fn zsh_paths_are_skipped_even_when_resolved_shell_is_sh() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("example.zsh"),
cache_rel_path: PathBuf::from("example.zsh"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(fixture_looks_like_zsh(&fixture));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
}
#[test]
fn zsh_shebangs_are_selected_for_large_corpus_zsh_parse() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("bin/plugin"),
cache_rel_path: PathBuf::from("bin/plugin"),
shell: "zsh".into(),
source_hash: String::new(),
};
assert_eq!(effective_large_corpus_shell(&fixture), "zsh");
assert!(fixture_selected_for_large_corpus_zsh_parse(&fixture));
}
#[test]
fn static_zsh_override_paths_are_selected_for_large_corpus_zsh_parse() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("ohmyzsh__ohmyzsh__tools__check_for_upgrade.sh"),
cache_rel_path: PathBuf::from("ohmyzsh__ohmyzsh__tools__check_for_upgrade.sh"),
shell: "sh".into(),
source_hash: String::new(),
};
assert_eq!(effective_large_corpus_shell(&fixture), "zsh");
assert!(fixture_selected_for_large_corpus_zsh_parse(&fixture));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
}
#[test]
fn zsh_paths_force_effective_zsh_shell() {
let fixture = LargeCorpusFixture {
path: PathBuf::from(".zshrc"),
cache_rel_path: PathBuf::from(".zshrc"),
shell: "sh".into(),
source_hash: String::new(),
};
assert_eq!(effective_large_corpus_shell(&fixture), "zsh");
assert!(fixture_selected_for_large_corpus_zsh_parse(&fixture));
}
#[test]
fn flattened_repo_git_entries_are_skipped() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("repo__.git__hooks__pre-commit.sample"),
cache_rel_path: PathBuf::from("repo__.git__hooks__pre-commit.sample"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(fixture_is_repo_git_entry(&fixture));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
}
#[test]
fn sample_files_are_skipped_for_large_corpus() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("hooks/pre-commit.sample"),
cache_rel_path: PathBuf::from("hooks/pre-commit.sample"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(path_is_sample_file(&fixture.path));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
}
#[test]
fn fish_files_are_skipped_for_large_corpus() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("functions/prompt.fish"),
cache_rel_path: PathBuf::from("functions/prompt.fish"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(path_is_fish_file(&fixture.path));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
}
#[test]
fn patch_files_are_skipped_for_large_corpus() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("patches/fixup.patch"),
cache_rel_path: PathBuf::from("patches/fixup.patch"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(path_is_patch_file(&fixture.path));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
assert!(!fixture_selected_for_large_corpus_zsh_parse(&fixture));
}
#[test]
fn static_ignore_paths_are_skipped_for_large_corpus() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("super-linter__super-linter__test__linters__bash__shell_bad_1.sh"),
cache_rel_path: PathBuf::from(
"super-linter__super-linter__test__linters__bash__shell_bad_1.sh",
),
shell: "bash".into(),
source_hash: String::new(),
};
assert!(path_is_statically_ignored_large_corpus_fixture(
&fixture.path
));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
assert!(!fixture_selected_for_large_corpus_zsh_parse(&fixture));
}
#[test]
fn reviewed_shellcheck_parse_abort_fixture_is_downgraded_to_warning() {
let details = vec!["shellcheck parse aborted".to_owned()];
let path = Path::new("pyenv__pyenv__plugins__python-build__bin__python-build");
let mut evaluation = FixtureEvaluation::default();
assert!(record_reviewed_large_corpus_harness_warning(
&mut evaluation,
path,
&details
));
assert_eq!(
evaluation.harness_failure.as_ref().unwrap().kind,
FixtureFailureKind::Warning
);
assert!(
evaluation
.harness_failure
.as_ref()
.unwrap()
.message
.contains("reviewed fixture exception")
);
}
#[test]
fn reviewed_shellcheck_parse_abort_fixture_with_shuck_parse_error_stays_blocking() {
let details = vec![
"shuck parse error: parse error at line 42, column 7: expected 'fi'".to_owned(),
"shellcheck parse aborted".to_owned(),
];
let path = Path::new("pyenv__pyenv__plugins__python-build__bin__python-build");
let mut evaluation = FixtureEvaluation::default();
assert!(!record_reviewed_large_corpus_harness_warning(
&mut evaluation,
path,
&details
));
assert!(evaluation.harness_failure.is_none());
}
#[test]
fn reviewed_shuck_parse_exception_fixture_is_downgraded_to_warning() {
let details = vec![
"shuck parse error: parse error at line 1325, column 2: expected ')' after case pattern"
.to_owned(),
"shellcheck parsed fixture successfully".to_owned(),
];
let path = Path::new("paulirish__dotfiles__.git-completion.bash");
let mut evaluation = FixtureEvaluation::default();
assert!(record_reviewed_large_corpus_harness_warning(
&mut evaluation,
path,
&details
));
assert_eq!(
evaluation.harness_failure.as_ref().unwrap().kind,
FixtureFailureKind::Warning
);
}
#[test]
fn guess_files_are_skipped_for_large_corpus() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("termux__termux-packages__scripts__config.guess"),
cache_rel_path: PathBuf::from("termux__termux-packages__scripts__config.guess"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(path_is_guess_file(&fixture.path));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
assert!(!fixture_selected_for_large_corpus_zsh_parse(&fixture));
}
#[test]
fn config_sub_files_are_skipped_for_large_corpus() {
let fixture = LargeCorpusFixture {
path: PathBuf::from("termux__termux-packages__scripts__config.sub"),
cache_rel_path: PathBuf::from("termux__termux-packages__scripts__config.sub"),
shell: "sh".into(),
source_hash: String::new(),
};
assert!(path_is_config_sub_file(&fixture.path));
assert!(!fixture_supported_for_large_corpus(&fixture, None));
assert!(!fixture_selected_for_large_corpus_zsh_parse(&fixture));
}
#[test]
fn shellcheck_parse_abort_classification() {
let aborted = vec![
ShellCheckDiagnostic {
file: String::new(),
code: 1073,
line: 1,
end_line: 1,
column: 1,
end_column: 1,
level: "error".into(),
message: "Couldn't parse this test expression.".into(),
fix: None,
},
ShellCheckDiagnostic {
file: String::new(),
code: 1072,
line: 2,
end_line: 2,
column: 1,
end_column: 1,
level: "error".into(),
message: "Expected comparison operator. Fix any mentioned problems and try again."
.into(),
fix: None,
},
];
assert!(shellcheck_parse_aborted(&aborted));
let parsing_stopped = vec![ShellCheckDiagnostic {
file: String::new(),
code: 1088,
line: 3,
end_line: 3,
column: 1,
end_column: 1,
level: "error".into(),
message: "Parsing stopped here. Invalid use of parentheses?".into(),
fix: None,
}];
assert!(shellcheck_parse_aborted(&parsing_stopped));
let non_aborted = vec![ShellCheckDiagnostic {
file: String::new(),
code: 3011,
line: 1,
end_line: 1,
column: 1,
end_column: 1,
level: "warning".into(),
message: "In POSIX sh, here-strings are undefined.".into(),
fix: None,
}];
assert!(!shellcheck_parse_aborted(&non_aborted));
}
#[test]
fn compatibility_diff_detects_over_and_underreporting() {
let want = HashMap::from([("SC2086".to_string(), 1)]);
let got_over = HashMap::from([("SC2086".to_string(), 2)]);
let got_under = HashMap::from([("SC2086".to_string(), 0)]);
assert!(compatibility_code_diff(&want, &got_over, DiffMode::All).is_some());
assert!(compatibility_code_diff(&want, &got_under, DiffMode::All).is_some());
}
#[test]
fn count_codes_aggregates() {
let codes = vec!["SC2086".into(), "SC2086".into(), "SC2154".into()];
let counts = count_codes(&codes);
assert_eq!(counts["SC2086"], 2);
assert_eq!(counts["SC2154"], 1);
}
#[test]
fn mapped_only_filter_drops_unmapped_shellcheck_diagnostics() {
let mapped_shellcheck_codes = build_mapped_shellcheck_codes();
let run = ShellCheckRun {
diagnostics: vec![
shellcheck_diagnostic(2034),
shellcheck_diagnostic(2086),
shellcheck_diagnostic(9999),
],
parse_aborted: true,
};
let filtered = filter_shellcheck_run(run, Some(&mapped_shellcheck_codes));
let codes = filtered
.diagnostics
.iter()
.map(|diag| diag.code)
.collect::<Vec<_>>();
assert_eq!(codes, vec![2034, 2086]);
assert!(filtered.parse_aborted);
}
#[test]
fn large_corpus_uses_the_shared_shellcheck_map() {
let index = build_shellcheck_to_rule_index(None);
assert_eq!(index.get(&3034), Some(&vec!["X026".to_string()]));
assert_eq!(index.get(&2096), Some(&vec!["S053".to_string()]));
assert_eq!(index.get(&2086), Some(&vec!["S001".to_string()]));
}
#[test]
fn selected_rule_shellcheck_filters_include_active_rule_codes_without_aliases() {
let selected_rules =
shuck_linter::RuleSet::from_iter([shuck_linter::Rule::FunctionKeywordInSh]);
let codes = build_selected_shellcheck_codes(&selected_rules);
assert_eq!(codes, HashSet::from([2112]));
}
#[test]
fn rule_corpus_metadata_path_uses_rule_code_convention() {
assert_eq!(
rule_corpus_metadata_path("X123"),
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/testdata/corpus-metadata")
.join("x123.yaml")
);
}
#[test]
fn load_all_rule_corpus_metadata_keeps_c001_review_allowlist_and_scoped_entries() {
let metadata = load_all_rule_corpus_metadata();
assert!(metadata.get("C001").is_some_and(|rule_metadata| {
rule_metadata.review_all_divergences && !rule_metadata.reviewed_divergences.is_empty()
}));
assert!(
metadata
.get("C057")
.and_then(|rule_metadata| rule_metadata.reviewed_divergences.first())
.is_some_and(|entry| {
!entry.rule_wide
&& entry.path_suffix.as_deref()
== Some("bittorf__kalua__openwrt-addons__etc__kalua__watch")
&& entry.line == Some(1336)
})
);
}
#[test]
fn reviewed_divergence_classification_matches_exact_shellcheck_record() {
let metadata = HashMap::from([(
"C999".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShellcheckOnly,
rule_wide: false,
path_suffix: Some("repo__script.sh".into()),
path_contains: None,
line: Some(19),
end_line: Some(19),
column: Some(1),
end_column: Some(14),
reason: "exact-match reviewed divergence".into(),
}],
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShellcheckOnly,
rule_code: Some("C999".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC2034".into(),
range: DiagnosticRange {
line: 19,
end_line: 19,
column: 1,
end_column: 14,
},
message: "warning reviewed divergence".into(),
};
let (classification, reason) =
classify_compatibility_record(&record, "repo__script.sh", &metadata);
assert_eq!(
classification,
CompatibilityClassification::ReviewedDivergence
);
assert_eq!(reason.as_deref(), Some("exact-match reviewed divergence"));
}
#[test]
fn reviewed_divergence_classification_matches_scripts_prefixed_path_suffix_record() {
let metadata = HashMap::from([(
"C999".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShuckOnly,
rule_wide: false,
path_suffix: Some("scripts/repo__script.sh".into()),
path_contains: None,
line: Some(19),
end_line: Some(19),
column: Some(1),
end_column: Some(14),
reason: "scripts-prefixed reviewed divergence".into(),
}],
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some("C999".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC2034".into(),
range: DiagnosticRange {
line: 19,
end_line: 19,
column: 1,
end_column: 14,
},
message: "warning reviewed divergence".into(),
};
let (classification, reason) =
classify_compatibility_record(&record, "repo__script.sh", &metadata);
assert_eq!(
classification,
CompatibilityClassification::ReviewedDivergence
);
assert_eq!(
reason.as_deref(),
Some("scripts-prefixed reviewed divergence")
);
}
#[test]
fn reviewed_divergence_classification_matches_rule_wide_record() {
let metadata = HashMap::from([(
"C999".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShuckOnly,
rule_wide: true,
path_suffix: None,
path_contains: None,
line: None,
end_line: None,
column: None,
end_column: None,
reason: "rule-wide reviewed divergence".into(),
}],
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some("C999".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC1090".into(),
range: DiagnosticRange {
line: 20,
end_line: 20,
column: 1,
end_column: 10,
},
message: "dynamic source path".into(),
};
let (classification, reason) =
classify_compatibility_record(&record, "repo__script.sh", &metadata);
assert_eq!(
classification,
CompatibilityClassification::ReviewedDivergence
);
assert_eq!(reason.as_deref(), Some("rule-wide reviewed divergence"));
}
#[test]
fn reviewed_divergence_classification_requires_path_and_line_constraints() {
let metadata = HashMap::from([(
"X065".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShuckOnly,
rule_wide: false,
path_suffix: Some("alpinelinux__aports__scripts__mkimage.sh".into()),
path_contains: None,
line: Some(120),
end_line: Some(120),
column: None,
end_column: None,
reason: "scoped reviewed divergence".into(),
}],
},
)]);
let matching = CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some("X065".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC3026".into(),
range: DiagnosticRange {
line: 120,
end_line: 120,
column: 22,
end_column: 34,
},
message: "caret negation in bracket expressions is not portable to POSIX sh".into(),
};
let wrong_path = CompatibilityRecord { ..matching.clone() };
let wrong_line = CompatibilityRecord {
range: DiagnosticRange {
line: 121,
end_line: 121,
column: 22,
end_column: 34,
},
..matching.clone()
};
let (matching_classification, matching_reason) = classify_compatibility_record(
&matching,
"alpinelinux__aports__scripts__mkimage.sh",
&metadata,
);
let (wrong_path_classification, wrong_path_reason) =
classify_compatibility_record(&wrong_path, "other__repo__script.sh", &metadata);
let (wrong_line_classification, wrong_line_reason) = classify_compatibility_record(
&wrong_line,
"alpinelinux__aports__scripts__mkimage.sh",
&metadata,
);
assert_eq!(
matching_classification,
CompatibilityClassification::ReviewedDivergence
);
assert_eq!(
matching_reason.as_deref(),
Some("scoped reviewed divergence")
);
assert_eq!(
wrong_path_classification,
CompatibilityClassification::Implementation
);
assert!(wrong_path_reason.is_none());
assert_eq!(
wrong_line_classification,
CompatibilityClassification::Implementation
);
assert!(wrong_line_reason.is_none());
}
#[test]
fn reviewed_divergence_classification_matches_path_contains_record() {
let metadata = HashMap::from([(
"C999".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShuckOnly,
rule_wide: false,
path_suffix: None,
path_contains: Some("termux__termux-packages__".into()),
line: None,
end_line: None,
column: None,
end_column: None,
reason: "path-contains reviewed divergence".into(),
}],
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some("C999".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC2034".into(),
range: DiagnosticRange {
line: 13,
end_line: 13,
column: 1,
end_column: 32,
},
message: "warning termux variable".into(),
};
let (classification, reason) = classify_compatibility_record(
&record,
"fixtures/termux__termux-packages__packages__foo__build.sh",
&metadata,
);
assert_eq!(
classification,
CompatibilityClassification::ReviewedDivergence
);
assert_eq!(reason.as_deref(), Some("path-contains reviewed divergence"));
}
#[test]
fn reviewed_divergence_path_contains_uses_cache_relative_path_only() {
let metadata = HashMap::from([(
"C999".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShuckOnly,
rule_wide: false,
path_suffix: None,
path_contains: Some("termux__termux-packages__".into()),
line: None,
end_line: None,
column: None,
end_column: None,
reason: "path-contains reviewed divergence".into(),
}],
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some("C999".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC2034".into(),
range: DiagnosticRange {
line: 13,
end_line: 13,
column: 1,
end_column: 32,
},
message: "warning unrelated variable".into(),
};
let (classification, reason) =
classify_compatibility_record(&record, "fixtures/unrelated.sh", &metadata);
assert_eq!(classification, CompatibilityClassification::Implementation);
assert!(reason.is_none());
}
#[test]
fn reviewed_divergence_validation_rejects_implicit_rule_wide_entry() {
let metadata = RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShellcheckOnly,
rule_wide: false,
path_suffix: None,
path_contains: None,
line: None,
end_line: None,
column: None,
end_column: None,
reason: "implicit rule-wide reviewed divergence".into(),
}],
};
let error = metadata
.validate(
"C999",
Path::new("tests/testdata/corpus-metadata/c999.yaml"),
)
.unwrap_err();
assert!(error.contains("add a scope field or set rule_wide: true"));
}
#[test]
fn reviewed_divergence_validation_rejects_rule_wide_entry_with_scope() {
let metadata = RuleCorpusMetadataDocument {
review_all_divergences: false,
reviewed_divergences: vec![ReviewedDivergenceRecord {
side: CompatibilitySide::ShellcheckOnly,
rule_wide: true,
path_suffix: Some("repo__script.sh".into()),
path_contains: None,
line: None,
end_line: None,
column: None,
end_column: None,
reason: "conflicting reviewed divergence".into(),
}],
};
let error = metadata
.validate(
"C999",
Path::new("tests/testdata/corpus-metadata/c999.yaml"),
)
.unwrap_err();
assert!(error.contains("rule-wide entries cannot also set path or range constraints"));
}
#[test]
fn allowlisted_large_corpus_rule_is_nonblocking() {
let metadata = HashMap::from([(
"C001".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: true,
reviewed_divergences: Vec::new(),
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShuckOnly,
rule_code: Some("C001".into()),
rule_codes: Vec::new(),
shellcheck_code: "SC2034".into(),
range: DiagnosticRange {
line: 13,
end_line: 13,
column: 1,
end_column: 32,
},
message: "warning unused assignment".into(),
};
let (classification, reason) =
classify_compatibility_record(&record, "fixtures/unrelated.sh", &metadata);
assert_eq!(
classification,
CompatibilityClassification::ReviewedDivergence
);
assert_eq!(
reason.as_deref(),
Some(LARGE_CORPUS_ALLOWED_FAILING_RULE_REASON)
);
}
#[test]
fn allowlisted_large_corpus_rule_requires_every_mapped_rule_to_be_allowlisted() {
let metadata = HashMap::from([(
"C001".to_string(),
RuleCorpusMetadataDocument {
review_all_divergences: true,
reviewed_divergences: Vec::new(),
},
)]);
let record = CompatibilityRecord {
side: CompatibilitySide::ShellcheckOnly,
rule_code: None,
rule_codes: vec!["C001".into(), "C999".into()],
shellcheck_code: "SC2034".into(),
range: DiagnosticRange {
line: 13,
end_line: 13,
column: 1,
end_column: 32,
},
message: "warning mixed mappings".into(),
};
let (classification, reason) =
classify_compatibility_record(&record, "fixtures/unrelated.sh", &metadata);
assert_eq!(classification, CompatibilityClassification::Implementation);
assert!(reason.is_none());
}
#[test]
fn keep_going_collects_multiple_fixture_failures() {
let first = fixture("first.sh");
let second = fixture("second.sh");
let fixtures = vec![&first, &second];
let seen = Mutex::new(Vec::new());
let failures = collect_fixture_failures(&fixtures, true, |fixture| {
seen.lock().unwrap().push(fixture.path.clone());
FixtureEvaluation {
implementation_diffs: vec![format_fixture_failure(
&fixture.path,
&[format!("{} failed", fixture.path.display())],
)],
..FixtureEvaluation::default()
}
});
let mut seen = seen.into_inner().unwrap();
seen.sort();
assert_eq!(
seen,
vec![PathBuf::from("first.sh"), PathBuf::from("second.sh")]
);
assert!(!failures.timeout_cap_reached);
assert_eq!(failures.implementation_diffs.len(), 2);
assert!(failures.implementation_diffs[0].contains("first.sh"));
assert!(failures.implementation_diffs[1].contains("second.sh"));
}
#[test]
fn clamp_large_corpus_worker_count_uses_machine_parallelism_with_cap() {
assert_eq!(clamp_large_corpus_worker_count(2, 10), 2);
assert_eq!(
clamp_large_corpus_worker_count(8, 10),
LARGE_CORPUS_MAX_WORKER_COUNT
);
assert_eq!(clamp_large_corpus_worker_count(8, 3), 3);
}
#[test]
fn mapping_issues_are_nonblocking() {
let fixture = fixture("mapping.sh");
let fixtures = vec![&fixture];
let failures = collect_fixture_failures(&fixtures, false, |fixture| FixtureEvaluation {
mapping_issues: vec![format_fixture_failure(
&fixture.path,
&[format!("{} needs mapping review", fixture.path.display())],
)],
..FixtureEvaluation::default()
});
assert_eq!(failures.blocking_failures(), 0);
assert_eq!(failures.mapping_issues.len(), 1);
assert!(failures.has_nonblocking_items());
}
#[test]
fn sequential_timeouts_become_harness_warnings() {
let fixture = fixture("timeout.sh");
let fixtures = vec![&fixture];
let failures = collect_fixture_failures(&fixtures, false, |fixture| FixtureEvaluation {
harness_failure: Some(FixtureFailure {
kind: FixtureFailureKind::Timeout,
message: format_fixture_failure(
&fixture.path,
&[format!(
"shuck error: {}",
format_timeout_message("shuck", Duration::from_secs(30))
)],
),
}),
..FixtureEvaluation::default()
});
assert_eq!(failures.blocking_failures(), 0);
assert_eq!(failures.harness_warnings.len(), 1);
assert!(failures.harness_failures.is_empty());
assert!(failures.has_nonblocking_items());
}
#[test]
fn failure_report_omits_nonblocking_sections() {
let report = format_large_corpus_failure_report(&FixtureFailureCollection {
implementation_diffs: vec![
"/tmp/blocking.sh\n shellcheck-only C001/SC2000 1:1-1:5 error blocking".into(),
],
mapping_issues: vec![
"/tmp/mapping.sh\n shellcheck-only C001/SC2000 1:1-1:5 warning mapping".into(),
],
reviewed_divergences: vec![
"/tmp/reviewed.sh\n shuck-only C001/SC2000 1:1-1:5 warning reviewed".into(),
],
harness_warnings: vec!["/tmp/timeout.sh\n shuck error: timed out".into()],
unsupported_shells: 2,
..FixtureFailureCollection::default()
});
assert!(report.contains("Implementation Diffs:"));
assert!(!report.contains("Mapping Issues:"));
assert!(!report.contains("Reviewed Divergence:"));
assert!(!report.contains("Harness Warnings:"));
assert!(report.contains("Nonblocking issue buckets were omitted"));
}
#[test]
fn timeout_cap_note_line_is_structured() {
assert_eq!(
timeout_cap_note_line("large corpus compatibility", true),
Some(format!(
"large corpus compatibility note: only the first {} fixture timeouts were recorded as harness warnings; additional timeout fixtures were omitted.",
LARGE_CORPUS_TIMEOUT_FAILURE_CAP
))
);
assert_eq!(
timeout_cap_note_line("large corpus compatibility", false),
None
);
}
#[test]
fn keep_going_captures_fixture_panics() {
let fixture = fixture("panic.sh");
let fixtures = vec![&fixture];
let failures = collect_fixture_failures(&fixtures, true, |_| -> FixtureEvaluation {
panic!("boom");
});
assert!(!failures.timeout_cap_reached);
assert_eq!(failures.harness_failures.len(), 1);
assert!(failures.harness_failures[0].contains("panic.sh"));
assert!(failures.harness_failures[0].contains("fixture panic: boom"));
}
#[test]
fn keep_going_keeps_evaluating_after_timeout_warning_cap() {
let fixtures: Vec<_> = (0..10)
.map(|i| fixture(&format!("timeout-{i}.sh")))
.collect();
let fixture_refs: Vec<_> = fixtures.iter().collect();
let seen = AtomicUsize::new(0);
let progress = LargeCorpusProgress::new(fixture_refs.len());
let failures = collect_fixture_failures_in_parallel(
&fixture_refs,
1,
&|fixture| {
seen.fetch_add(1, Ordering::Relaxed);
if fixture.path.ends_with("timeout-9.sh") {
return FixtureEvaluation {
implementation_diffs: vec![format_fixture_failure(
&fixture.path,
&[format!("{} failed", fixture.path.display())],
)],
..FixtureEvaluation::default()
};
}
FixtureEvaluation {
harness_failure: Some(FixtureFailure {
kind: FixtureFailureKind::Timeout,
message: format_fixture_failure(
&fixture.path,
&[format!(
"shuck error: {}",
format_timeout_message("shuck", Duration::from_secs(30))
)],
),
}),
..FixtureEvaluation::default()
}
},
&progress,
);
assert!(failures.timeout_cap_reached);
assert_eq!(
failures.harness_warnings.len(),
LARGE_CORPUS_TIMEOUT_FAILURE_CAP
);
assert!(failures.harness_failures.is_empty());
assert_eq!(seen.load(Ordering::Relaxed), fixture_refs.len());
assert_eq!(failures.implementation_diffs.len(), 1);
assert!(failures.implementation_diffs[0].contains("timeout-9.sh"));
assert!(
failures
.harness_warnings
.iter()
.all(|failure| failure.contains("timed out after"))
);
}
#[test]
fn zsh_stderr_parse_error_detection_ignores_non_parse_failures() {
assert!(zsh_stderr_looks_like_parse_error(
"script.zsh:4: parse error near `foo'"
));
assert!(zsh_stderr_looks_like_parse_error(
"script.zsh:8: unmatched \""
));
assert!(!zsh_stderr_looks_like_parse_error(
"script.zsh:32: no such file or directory:"
));
}
#[test]
fn decode_shellcheck_json_array() {
let data = br#"[{"code":1091,"line":12,"endLine":12,"column":3,"endColumn":15,"level":"info","message":"missing source"}]"#;
let diags = decode_shellcheck_diagnostics(data).unwrap();
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, 1091);
assert_eq!(diags[0].line, 12);
}
#[test]
fn decode_shellcheck_json_object() {
let data = br#"{"comments":[{"code":1091,"line":12,"endLine":12,"column":3,"endColumn":15,"level":"info","message":"missing source"}]}"#;
let diags = decode_shellcheck_diagnostics(data).unwrap();
assert_eq!(diags.len(), 1);
assert_eq!(diags[0].code, 1091);
}
#[test]
fn env_truthy_parses_values() {
assert!(!env_truthy("__SHUCK_TEST_NONEXISTENT_VAR__", false));
}
#[test]
fn format_range_handles_zeros() {
assert_eq!(format_range(1, 2, 0, 0), "1:2-1:2");
assert_eq!(format_range(1, 2, 3, 4), "1:2-3:4");
}
#[test]
fn shellcheck_cache_key_is_stable_across_worktree_paths() {
let tempdir = tempfile::tempdir().unwrap();
let cache = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let left = fixture_at(
Path::new("/tmp/worktree-a/.cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let right = fixture_at(
Path::new("/tmp/worktree-b/.cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
assert_eq!(cache.cache_path(&left), cache.cache_path(&right));
}
#[test]
fn shellcheck_cache_key_changes_with_shellcheck_version_text() {
let tempdir = tempfile::tempdir().unwrap();
let fixture = fixture_at(
Path::new("/tmp/worktree/.cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let first = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let second = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.1"));
assert_ne!(first.cache_path(&fixture), second.cache_path(&fixture));
}
#[test]
fn shellcheck_cache_prepare_renames_legacy_current_worktree_files() {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path().join("worktree");
let fixture = fixture_at(
&root.join(".cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let cache = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let stable_path = cache.cache_path(&fixture);
let legacy_path = cache.legacy_cache_path_for_absolute_path(&fixture, &fixture.path);
write_cache_file(&legacy_path, "legacy-current");
cache.prepare(std::slice::from_ref(&fixture), std::slice::from_ref(&root));
assert!(stable_path.is_file());
assert!(!legacy_path.exists());
assert_eq!(
fs::read_to_string(&stable_path).unwrap(),
cache_file_data("legacy-current")
);
}
#[test]
fn shellcheck_cache_prepare_renames_legacy_alternate_worktree_files() {
let tempdir = tempfile::tempdir().unwrap();
let current_root = tempdir.path().join("current");
let alternate_root = tempdir.path().join("alternate");
let fixture = fixture_at(
¤t_root.join(".cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let cache = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let stable_path = cache.cache_path(&fixture);
let alternate_legacy_path = cache.legacy_cache_path_for_absolute_path(
&fixture,
&alternate_root
.join(".cache")
.join("large-corpus")
.join("corpus")
.join("scripts")
.join("example.sh"),
);
write_cache_file(&alternate_legacy_path, "legacy-alternate");
cache.prepare(
std::slice::from_ref(&fixture),
&[current_root.clone(), alternate_root.clone()],
);
assert!(stable_path.is_file());
assert!(!alternate_legacy_path.exists());
assert_eq!(
fs::read_to_string(&stable_path).unwrap(),
cache_file_data("legacy-alternate")
);
}
#[test]
fn shellcheck_cache_prepare_is_idempotent() {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path().join("worktree");
let fixture = fixture_at(
&root.join(".cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let cache = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let stable_path = cache.cache_path(&fixture);
let legacy_path = cache.legacy_cache_path_for_absolute_path(&fixture, &fixture.path);
write_cache_file(&legacy_path, "legacy-current");
cache.prepare(std::slice::from_ref(&fixture), std::slice::from_ref(&root));
cache.prepare(std::slice::from_ref(&fixture), std::slice::from_ref(&root));
assert!(stable_path.is_file());
assert!(!legacy_path.exists());
assert_eq!(
fs::read_to_string(&stable_path).unwrap(),
cache_file_data("legacy-current")
);
}
#[test]
fn shellcheck_cache_prepare_keeps_existing_stable_file() {
let tempdir = tempfile::tempdir().unwrap();
let root = tempdir.path().join("worktree");
let fixture = fixture_at(
&root.join(".cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let cache = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let stable_path = cache.cache_path(&fixture);
let legacy_path = cache.legacy_cache_path_for_absolute_path(&fixture, &fixture.path);
write_cache_file(&stable_path, "stable");
write_cache_file(&legacy_path, "legacy-current");
cache.prepare(std::slice::from_ref(&fixture), std::slice::from_ref(&root));
assert!(stable_path.is_file());
assert!(!legacy_path.exists());
assert_eq!(
fs::read_to_string(&stable_path).unwrap(),
cache_file_data("stable")
);
}
#[test]
fn shellcheck_cache_ignores_entries_with_stale_schema() {
let tempdir = tempfile::tempdir().unwrap();
let fixture = fixture_at(
&tempdir
.path()
.join("worktree/.cache/large-corpus/scripts/example.sh"),
Path::new("example.sh"),
);
let cache = ShellCheckCache::new(tempdir.path(), &probe("version: 0.10.0"));
let stale_path = cache.cache_path(&fixture);
if let Some(parent) = stale_path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(
&stale_path,
serde_json::to_string(&ShellCheckCacheEntry {
schema: SHELLCHECK_CACHE_SCHEMA - 1,
diagnostics: vec![shellcheck_diagnostic(1088)],
parse_aborted: false,
})
.unwrap(),
)
.unwrap();
assert!(cache.read_cache(&fixture).is_none());
}
#[test]
fn large_corpus_candidate_resolution_uses_cache_relative_parent_dirs() {
let resolved = resolve_large_corpus_candidate_cache_rel_path(
Path::new("repo/pkg/main.sh"),
"../common/helper.sh",
);
assert_eq!(resolved, Some(PathBuf::from("repo/common/helper.sh")));
}
#[test]
fn large_corpus_path_resolver_keeps_helper_lookup_inside_current_repo() {
let tempdir = tempfile::tempdir().unwrap();
let scripts_dir = tempdir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let source = fixture_at(
&scripts_dir.join("repo__pkg__main.sh"),
Path::new("repo/pkg/main.sh"),
);
let local = fixture_at(
&scripts_dir.join("repo__pkg__build.sh"),
Path::new("repo/pkg/build.sh"),
);
let unrelated = fixture_at(
&scripts_dir.join("other__build.sh"),
Path::new("other/build.sh"),
);
fs::write(&source.path, "#!/bin/sh\n./build.sh\n").unwrap();
fs::write(&local.path, "echo local\n").unwrap();
fs::write(&unrelated.path, "echo unrelated\n").unwrap();
let resolver = LargeCorpusPathResolver::new(&[&source, &local, &unrelated]);
let resolved = shuck_semantic::SourcePathResolver::resolve_candidate_paths(
&resolver,
&source.path,
"./build.sh",
);
assert_eq!(resolved, vec![canonicalize_for_resolver(&local.path)]);
}
#[test]
fn large_corpus_path_resolver_can_resolve_repo_relative_static_tails() {
let tempdir = tempfile::tempdir().unwrap();
let scripts_dir = tempdir.path().join("scripts");
fs::create_dir_all(&scripts_dir).unwrap();
let source = fixture_at(
&scripts_dir.join("rvm__rvm__tests__fast__sample.sh"),
Path::new("rvm__rvm__tests__fast__sample.sh"),
);
let helper = fixture_at(
&scripts_dir.join("rvm__rvm__scripts__rvm"),
Path::new("rvm__rvm__scripts__rvm"),
);
fs::write(
&source.path,
"#!/bin/sh\nsource \"$rvm_path/scripts/rvm\"\n",
)
.unwrap();
fs::write(&helper.path, "echo helper\n").unwrap();
let resolver = LargeCorpusPathResolver::new(&[&source, &helper]);
let resolved = shuck_semantic::SourcePathResolver::resolve_candidate_paths(
&resolver,
&source.path,
"scripts/rvm",
);
assert_eq!(resolved, vec![canonicalize_for_resolver(&helper.path)]);
}
#[test]
fn collect_fixtures_skips_known_non_corpus_inputs() {
let tempdir = tempfile::tempdir().unwrap();
let scripts_dir = tempdir.path().join("scripts");
let nested_dir = scripts_dir.join("nested");
fs::create_dir_all(&nested_dir).unwrap();
fs::write(scripts_dir.join("keep.sh"), "#!/bin/sh\necho keep\n").unwrap();
fs::write(scripts_dir.join("._keep.sh"), "skip\n").unwrap();
fs::write(scripts_dir.join("config.guess"), "not a shell script\n").unwrap();
fs::write(
scripts_dir.join("build-aux-config.sub"),
"not a shell script\n",
)
.unwrap();
fs::write(
scripts_dir.join("termux__termux-packages__scripts__config.guess"),
"not a shell script\n",
)
.unwrap();
fs::write(
scripts_dir.join("termux__termux-packages__scripts__config.sub"),
"not a shell script\n",
)
.unwrap();
fs::write(
scripts_dir.join("pre-commit.sample"),
"#!/bin/sh\necho skip\n",
)
.unwrap();
fs::write(scripts_dir.join("config.fish"), "echo skip\n").unwrap();
fs::write(scripts_dir.join("fixup.patch"), "--- a/file\n+++ b/file\n").unwrap();
fs::write(
scripts_dir.join("super-linter__super-linter__test__linters__bash__shell_bad_1.sh"),
"#!/usr/bin/env bash\necho skip\n",
)
.unwrap();
fs::write(
scripts_dir.join("alpinelinux__aports__community__starship__starship.plugin.zsh"),
"echo skip\n",
)
.unwrap();
fs::write(
scripts_dir.join("google__oss-fuzz__infra__chronos__coverage_test_collection.py"),
"#!/bin/bash -eux\nprint('skip')\n",
)
.unwrap();
fs::write(
scripts_dir.join("ohmyzsh__ohmyzsh__plugins__alias-finder__tests__test_run.sh"),
"#!/usr/bin/env zunit\n@setup {\n echo skip\n}\n",
)
.unwrap();
fs::write(
nested_dir.join("post-checkout.sample"),
"#!/bin/sh\necho skip\n",
)
.unwrap();
fs::write(nested_dir.join("prompt.fish"), "echo skip\n").unwrap();
fs::write(nested_dir.join("fixup.diff"), "--- a/file\n+++ b/file\n").unwrap();
fs::write(nested_dir.join("._nested.sh"), "skip\n").unwrap();
fs::write(nested_dir.join("toolchain.guess"), "not a shell script\n").unwrap();
fs::write(
nested_dir.join("toolchain-config.sub"),
"not a shell script\n",
)
.unwrap();
let fixtures = collect_fixtures(tempdir.path());
let collected_paths: Vec<_> = fixtures
.into_iter()
.map(|fixture| fixture.cache_rel_path)
.collect();
assert_eq!(collected_paths, vec![PathBuf::from("keep.sh")]);
}
fn fixture(path: &str) -> LargeCorpusFixture {
LargeCorpusFixture {
path: PathBuf::from(path),
cache_rel_path: PathBuf::from(path),
shell: "sh".into(),
source_hash: String::new(),
}
}
fn timing_record(
fixture_label: &str,
millis: u64,
outcome: LargeCorpusTimingOutcome,
) -> LargeCorpusTimingRecord {
LargeCorpusTimingRecord {
fixture_label: fixture_label.into(),
elapsed: Duration::from_millis(millis),
outcome,
}
}
fn shellcheck_diagnostic(code: u32) -> ShellCheckDiagnostic {
ShellCheckDiagnostic {
file: String::new(),
code,
line: 1,
end_line: 1,
column: 1,
end_column: 1,
level: "error".into(),
message: format!("diagnostic {code}"),
fix: None,
}
}
fn fixture_at(path: &Path, cache_rel_path: &Path) -> LargeCorpusFixture {
LargeCorpusFixture {
path: path.to_path_buf(),
cache_rel_path: cache_rel_path.to_path_buf(),
shell: "sh".into(),
source_hash: "source-hash".into(),
}
}
#[test]
fn run_shuck_reports_parse_error_when_parse_rule_is_disabled() {
let tempdir = tempfile::tempdir().unwrap();
let fixture_path = tempdir.path().join("broken.sh");
fs::write(&fixture_path, "#!/bin/sh\nif true; then\n :\n").unwrap();
let fixture = fixture_at(&fixture_path, Path::new("broken.sh"));
let run = run_shuck_with_parse_dialect(
&fixture,
&shuck_linter::LinterSettings::for_rule(shuck_linter::Rule::UnusedAssignment),
None,
shuck_parser::ShellDialect::Posix,
"sh",
);
assert!(run.diagnostics.is_empty());
assert!(
run.parse_error
.as_deref()
.is_some_and(|error| error.contains("expected 'fi'"))
);
}
#[test]
fn build_large_corpus_linter_settings_enables_c001_shellcheck_compat() {
let settings = build_large_corpus_linter_settings(None, false);
assert!(
large_corpus_uses_c001_shellcheck_compat(&settings),
"{settings:?}"
);
assert_eq!(
large_corpus_c001_compat_note_line(&settings),
Some(LARGE_CORPUS_C001_COMPAT_NOTE)
);
}
#[test]
fn large_corpus_c001_compat_note_only_applies_when_c001_is_enabled() {
let settings = build_large_corpus_linter_settings(None, false);
assert!(large_corpus_uses_c001_shellcheck_compat(&settings));
let without_c001 = shuck_linter::LinterSettings::for_rule(shuck_linter::Rule::EmptyTest)
.with_c001_treat_indirect_expansion_targets_as_used(false);
assert!(!large_corpus_uses_c001_shellcheck_compat(&without_c001));
assert_eq!(large_corpus_c001_compat_note_line(&without_c001), None);
}
fn probe(version_text: &str) -> ShellCheckProbe {
ShellCheckProbe {
command: "shellcheck".into(),
version_text: version_text.into(),
}
}
fn write_cache_file(path: &Path, label: &str) {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, cache_file_data(label)).unwrap();
}
fn cache_file_data(label: &str) -> String {
serde_json::to_string(&ShellCheckCacheEntry {
schema: SHELLCHECK_CACHE_SCHEMA,
diagnostics: vec![ShellCheckDiagnostic {
file: String::new(),
code: 2034,
line: 1,
end_line: 1,
column: 1,
end_column: 1,
level: "warning".into(),
message: label.into(),
fix: None,
}],
parse_aborted: false,
})
.unwrap()
}
}