pub mod js;
pub mod python;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TargetClass {
Host,
Llm,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum TransportClass {
Tracked,
UntrackedKnown,
Unknown,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct CallSite {
pub file: String,
pub line: u32,
pub callee: String,
pub function: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SubprocessSighting {
pub file: String,
pub line: u32,
pub launcher: String,
pub command: String,
pub argv: Option<Vec<String>>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct SimplificationSighting {
pub file: String,
pub line: u32,
pub kind: String,
pub function: String,
pub targets: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct OrchestrationSighting {
pub file: String,
pub line: u32,
pub kind: String,
pub snippet: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct DepAverseFile {
pub file: String,
pub reason: String,
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Sighting {
pub file: String,
pub line: u32,
}
impl Sighting {
pub fn label(&self) -> String {
format!("{}:{}", self.file, self.line)
}
}
#[derive(Debug, Clone)]
pub struct TargetEvidence {
pub class: TargetClass,
pub sightings: BTreeSet<Sighting>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct FunctionFacts {
pub entrypoint: String,
pub file: String,
pub line: u32,
pub effects: u32,
pub idempotent_unsafe: u32,
pub time_reads: u32,
pub random_reads: u32,
pub unsafe_reasons: Vec<String>,
pub targets: BTreeSet<String>,
}
#[derive(Debug, Clone, Default)]
pub struct ScanResult {
pub files_scanned: usize,
pub python_available: bool,
pub targets: BTreeMap<String, TargetEvidence>,
pub libs: BTreeSet<String>,
pub functions: Vec<FunctionFacts>,
pub resilience_libs: BTreeSet<String>,
pub host_transports: BTreeMap<String, TransportClass>,
pub subprocesses: Vec<SubprocessSighting>,
pub dependency_averse: Vec<DepAverseFile>,
pub simplifications: Vec<SimplificationSighting>,
pub orchestration: Vec<OrchestrationSighting>,
}
impl ScanResult {
fn add(&mut self, target: String, class: TargetClass, file: String, line: u32) {
self.targets
.entry(target)
.or_insert_with(|| TargetEvidence {
class,
sightings: BTreeSet::new(),
})
.sightings
.insert(Sighting { file, line });
}
}
pub fn scan(project: &Path) -> ScanResult {
let mut result = ScanResult::default();
let py = python::scan(project);
result.python_available = py.available;
result.files_scanned += py.files_scanned;
merge_lang(&mut result, &py.findings);
result.functions.extend(py.functions);
let js = js::scan(project);
result.files_scanned += js.files_scanned;
merge_lang(&mut result, &js.findings);
result.functions.extend(js.functions);
result.orchestration = scan_orchestration(project);
result
.functions
.sort_by(|a, b| (&a.file, a.line, &a.entrypoint).cmp(&(&b.file, b.line, &b.entrypoint)));
result.subprocesses.sort();
result.dependency_averse.sort();
result.simplifications.sort();
result
}
#[derive(Debug, Clone, Default)]
pub struct LangFindings {
pub llm: Vec<(String, Sighting)>,
pub hosts: Vec<(String, Sighting)>,
pub http_in_use: bool,
pub libs: BTreeSet<String>,
pub call_sites: Vec<CallSite>,
pub resilience_libs: BTreeSet<String>,
pub host_transports: BTreeMap<String, TransportClass>,
pub subprocesses: Vec<SubprocessSighting>,
pub dependency_averse: Vec<DepAverseFile>,
pub simplifications: Vec<SimplificationSighting>,
}
fn merge_lang(result: &mut ScanResult, f: &LangFindings) {
for (provider, s) in &f.llm {
result.add(
format!("llm:{provider}"),
TargetClass::Llm,
s.file.clone(),
s.line,
);
}
if f.http_in_use {
for (host, s) in &f.hosts {
result.add(host.clone(), TargetClass::Host, s.file.clone(), s.line);
}
}
for lib in &f.libs {
result.libs.insert(lib.clone());
}
for lib in &f.resilience_libs {
result.resilience_libs.insert(lib.clone());
}
for (host, class) in &f.host_transports {
result
.host_transports
.entry(host.clone())
.and_modify(|c| *c = (*c).min(*class))
.or_insert(*class);
}
result.subprocesses.extend(f.subprocesses.iter().cloned());
result
.dependency_averse
.extend(f.dependency_averse.iter().cloned());
result
.simplifications
.extend(f.simplifications.iter().cloned());
}
pub(crate) const SKIP_DIRS: &[&str] = &[
".keel",
".git",
".hg",
".svn",
"__pycache__",
"node_modules",
".venv",
"venv",
".mypy_cache",
".pytest_cache",
"dist",
"build",
"target",
];
pub(crate) fn collect_matching(
dir: &Path,
keep: &dyn Fn(&Path) -> bool,
descend_dot_dirs: &[&str],
out: &mut Vec<PathBuf>,
) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let name = entry.file_name();
let name = name.to_string_lossy();
if path.is_dir() {
if SKIP_DIRS.contains(&name.as_ref())
|| (name.starts_with('.') && !descend_dot_dirs.contains(&name.as_ref()))
{
continue;
}
collect_matching(&path, keep, descend_dot_dirs, out);
} else if keep(&path) {
out.push(path);
}
}
}
pub(crate) fn collect_files(dir: &Path, extensions: &[&str], out: &mut Vec<PathBuf>) {
collect_matching(
dir,
&|p| {
p.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| extensions.contains(&e))
},
&[],
out,
);
}
const ORCH_EXTS: &[&str] = &["sh", "bash", "zsh", "mk"];
const ORCH_NAMES: &[&str] = &["Makefile", "makefile", "GNUmakefile"];
const ORCH_CI_PATHS: &[&str] = &[".gitlab-ci.yml", ".circleci/config.yml"];
const ORCH_SCRIPT_DIRS: &[&str] = &["bin", "script", "scripts", "tools", "hooks"];
fn is_orchestration_file(path: &Path, project: &Path) -> bool {
let ext = path.extension().and_then(|e| e.to_str());
if ext.is_some_and(|e| ORCH_EXTS.contains(&e)) {
return true;
}
if path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| ORCH_NAMES.contains(&n))
{
return true;
}
if ext.is_none()
&& path
.parent()
.and_then(Path::file_name)
.and_then(|n| n.to_str())
.is_some_and(|n| ORCH_SCRIPT_DIRS.contains(&n))
{
return true; }
let Ok(rel) = path.strip_prefix(project) else {
return false;
};
let rel = rel.to_string_lossy().replace('\\', "/");
if ORCH_CI_PATHS.contains(&rel.as_str()) {
return true;
}
rel.starts_with(".github/workflows/") && matches!(ext, Some("yml" | "yaml"))
}
fn is_shell_shebang(text: &str) -> bool {
text.lines().next().is_some_and(|first| {
first.starts_with("#!")
&& ["sh", "bash", "zsh", "dash", "ksh"]
.iter()
.any(|s| first.contains(s))
})
}
pub(crate) fn scan_orchestration_text(rel: &str, text: &str) -> Vec<OrchestrationSighting> {
const FILE_TESTS: &[&str] = &["[ -f ", "[ -e ", "[[ -f ", "[[ -e ", "test -f ", "test -e "];
const GUARDISH: &[&str] = &["lock", "guard", ".pid", "stamp", "sentinel", "already"];
let mut out = Vec::new();
for (i, raw) in text.lines().enumerate() {
let l = raw.trim_start();
if l.starts_with('#') {
continue; }
let lower = l.to_ascii_lowercase();
let kind = if lower.contains("flock")
|| lower.contains("lockfile")
|| lower.contains("setlock")
|| (lower.contains("mkdir") && lower.contains("lock"))
{
Some("lockfile-mutex")
} else if lower.contains("kill -0") || lower.contains("kill -s 0") {
Some("pid-check")
} else if FILE_TESTS.iter().any(|t| l.contains(t))
&& (GUARDISH.iter().any(|g| lower.contains(g)) || lower.contains("exit 0"))
{
Some("guard-file")
} else {
None
};
if let Some(kind) = kind {
out.push(OrchestrationSighting {
file: rel.to_owned(),
line: u32::try_from(i).unwrap_or(u32::MAX).saturating_add(1),
kind: kind.to_owned(),
snippet: raw.trim().chars().take(120).collect(),
});
}
}
out
}
pub(crate) fn scan_orchestration(project: &Path) -> Vec<OrchestrationSighting> {
const MAX_BYTES: u64 = 512 * 1024;
let mut files = Vec::new();
collect_matching(
project,
&|p| is_orchestration_file(p, project),
&[".github", ".circleci"],
&mut files,
);
files.sort();
let mut out = Vec::new();
for f in files {
if std::fs::metadata(&f).is_ok_and(|m| m.len() > MAX_BYTES) {
continue;
}
let Ok(text) = std::fs::read_to_string(&f) else {
continue; };
let is_named_makefile = f
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| ORCH_NAMES.contains(&n));
if f.extension().is_none() && !is_named_makefile && !is_shell_shebang(&text) {
continue;
}
let rel = f
.strip_prefix(project)
.unwrap_or(&f)
.to_string_lossy()
.replace('\\', "/");
out.extend(scan_orchestration_text(&rel, &text));
}
out.sort();
out
}
pub(crate) fn host_from_url(s: &str) -> Option<String> {
let s = s.trim();
let (scheme, rest) = s.split_once("://")?;
if scheme.is_empty()
|| !scheme
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
|| !scheme
.chars()
.next()
.is_some_and(|c| c.is_ascii_alphabetic())
{
return None;
}
let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
let host_port = authority.rsplit('@').next().unwrap_or(authority);
let host = host_port.split(':').next().unwrap_or(host_port);
if host.is_empty() || host.contains(|c: char| c.is_whitespace()) {
return None;
}
Some(host.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn host_extraction_strips_port_userinfo_and_path() {
assert_eq!(
host_from_url("https://api.stripe.com/v1/x").as_deref(),
Some("api.stripe.com")
);
assert_eq!(
host_from_url("postgres://u:p@db.internal:5432/app").as_deref(),
Some("db.internal")
);
assert_eq!(host_from_url("HTTPS://API.X").as_deref(), Some("api.x"));
assert_eq!(host_from_url("not a url"), None);
assert_eq!(host_from_url("://nohost"), None);
assert_eq!(host_from_url("1bad://x"), None);
}
#[test]
fn orchestration_text_flags_the_at_most_once_signature() {
let text = "\
#!/usr/bin/env bash
set -euo pipefail
flock -n /tmp/run.lock || exit 0
if [ -f /var/run/autonomous.guard ]; then exit 0; fi
kill -0 \"$PID\" 2>/dev/null && echo running
echo work
";
let hits = scan_orchestration_text("scripts/run_autonomous.sh", text);
let kinds: BTreeSet<&str> = hits.iter().map(|h| h.kind.as_str()).collect();
assert!(kinds.contains("lockfile-mutex"), "kinds: {kinds:?}");
assert!(kinds.contains("guard-file"), "kinds: {kinds:?}");
assert!(kinds.contains("pid-check"), "kinds: {kinds:?}");
let lock = hits.iter().find(|h| h.kind == "lockfile-mutex").unwrap();
assert_eq!(lock.line, 3, "flock line");
}
#[test]
fn orchestration_text_is_quiet_on_ordinary_scripts() {
let text = "\
#!/bin/sh
echo hello
cp a b
[ -f .env ] && . .env
test -f target/release/keel || cargo build
[ -f \"$CONFIG\" ] || exit 1
if [ -e node_modules ]; then echo deps; fi
npm ci --package-lock-only
";
let hits = scan_orchestration_text("build.sh", text);
assert!(hits.is_empty(), "false positives: {hits:?}");
}
#[test]
fn orchestration_text_skips_comments() {
let text = "# flock -n /tmp/x.lock || exit 0\n\t# kill -0 $PID\n";
assert!(scan_orchestration_text("Makefile", text).is_empty());
}
#[test]
fn scan_sights_shell_makefile_and_ci_orchestrators_end_to_end() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/run_autonomous.sh"),
"#!/bin/bash\nflock -n /tmp/x.lock || exit 0\n",
)
.unwrap();
fs::write(
dir.path().join("Makefile"),
"deploy:\n\tkill -0 $$(cat run.pid) && exit 0\n",
)
.unwrap();
fs::create_dir_all(dir.path().join(".github/workflows")).unwrap();
fs::write(
dir.path().join(".github/workflows/cron.yml"),
"jobs:\n x:\n steps:\n - run: flock -n /tmp/ci.lock -c ./deploy.sh\n",
)
.unwrap();
let scan = scan(dir.path());
let files: BTreeSet<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
assert!(files.contains("scripts/run_autonomous.sh"), "{files:?}");
assert!(files.contains("Makefile"), "{files:?}");
assert!(files.contains(".github/workflows/cron.yml"), "{files:?}");
let mut sorted = scan.orchestration.clone();
sorted.sort();
assert_eq!(sorted, scan.orchestration);
}
#[test]
fn extensionless_scripts_need_a_shell_shebang() {
let dir = TempDir::new().unwrap();
fs::create_dir_all(dir.path().join("bin")).unwrap();
fs::write(
dir.path().join("bin/deploy"),
"#!/bin/sh\nflock -n /tmp/d.lock\n",
)
.unwrap();
fs::write(dir.path().join("bin/NOTES"), "flock is used here\n").unwrap();
let scan = scan(dir.path());
let files: BTreeSet<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
assert!(files.contains("bin/deploy"), "{files:?}");
assert!(!files.contains("bin/NOTES"), "{files:?}");
}
#[test]
fn orchestration_walk_skips_vendored_and_build_dirs() {
let dir = TempDir::new().unwrap();
for d in ["node_modules", "target", ".venv"] {
fs::create_dir_all(dir.path().join(d)).unwrap();
fs::write(dir.path().join(d).join("x.sh"), "flock -n /tmp/x.lock\n").unwrap();
}
assert!(scan(dir.path()).orchestration.is_empty());
}
}