keel_cli/scan/mod.rs
1//! Static scanning — the first of the three evidence sources behind `keel init`
2//! and `keel doctor` (dx-spec §2). No code runs: we read the project's source
3//! and find where effects enter.
4//!
5//! Two scanners, one merged result:
6//! - [`python`] shells an `ast`-walker out to `python3 -` for precise Python
7//! parsing (imports of known effect libraries, URL/DSN string literals).
8//! - [`js`] parses JS/TS/JSX in-process with oxc (no Node toolchain needed)
9//! for `fetch`/`undici`/`node:http` usage, provider-SDK imports, effect-lib
10//! call sites, and URL literals.
11//!
12//! Both label every finding with `file:line`, so the generated `keel.toml` can
13//! cite where each target was found and trust stays inspectable.
14
15pub mod js;
16pub mod python;
17
18use std::collections::{BTreeMap, BTreeSet};
19use std::path::{Path, PathBuf};
20
21/// What kind of target a sighting resolves to. Governs the policy block
22/// `keel init` writes (an `llm:*` target gets the LLM pack; a host gets the
23/// outbound pack).
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum TargetClass {
26 /// A network host, e.g. `api.stripe.com` — from a URL/DSN literal.
27 Host,
28 /// A semantic `llm:<provider>` target — from a provider SDK import.
29 Llm,
30}
31
32/// How a sighted host's traffic is dispatched, best-known across sightings.
33/// Ordering is meaningful: `Tracked < UntrackedKnown < Unknown`, so merging
34/// (`min`) always keeps the most favorable class seen for a host across every
35/// file/language that sighted it. This is what `keel doctor` (a later
36/// program task) uses to say honestly what Keel can and cannot see: a host
37/// is `Tracked` if some sighting reached it through a registry-adapted
38/// library, `UntrackedKnown` if the best reach was a known-but-unadapted
39/// transport (`http.client`, or urllib without `urllib.request`; Python's
40/// `urllib.request` itself is adapted), and `Unknown` if no transport
41/// evidence was found near any sighting at all.
42#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
43pub enum TransportClass {
44 /// A registry-adapted library is in reach — Keel can wrap this.
45 Tracked,
46 /// A known transport Keel does not adapt (http.client, or urllib without
47 /// `urllib.request`; Python's `urllib.request` itself is adapted).
48 UntrackedKnown,
49 /// A URL literal with no recognizable transport nearby.
50 Unknown,
51}
52
53/// One effect call site with enclosing-function attribution — an internal
54/// detail of the JS/TS pass ([`js`]), which uses it to verify its real
55/// scope-chain tracking (dotted paths like `Class.method`) independently of
56/// the coarser top-level-only [`FunctionFacts`] attribution `keel flows
57/// suggest` consumes. Not exposed on [`ScanResult`]. Field order is the sort
58/// order (file, then line).
59#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
60pub struct CallSite {
61 /// Project-relative path with `/` separators.
62 pub file: String,
63 /// 1-based line of the call expression.
64 pub line: u32,
65 /// What is called, rooted at the effect library where the receiver is
66 /// known (`fetch`, `undici.request`, `openai.chat.completions.create`).
67 pub callee: String,
68 /// Dotted enclosing-scope path (`Class.method`, `outer.inner`), or `None`
69 /// at module top level. Anonymous scopes inherit the nearest named scope.
70 pub function: Option<String>,
71}
72
73/// One externally-launched process the scan saw — traffic inside it is
74/// outside Keel's visibility regardless of policy. Field order is the sort
75/// order (file, then line).
76#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
77pub struct SubprocessSighting {
78 /// Project-relative path with `/` separators.
79 pub file: String,
80 /// 1-based line of the launching call.
81 pub line: u32,
82 /// The launching call, e.g. `subprocess.run`, `os.system`,
83 /// `child_process.spawn`.
84 pub launcher: String,
85 /// The literal argv/command line when statically extractable
86 /// (a bare string, or a list/tuple of string-literal elements); otherwise
87 /// `"<dynamic>"`.
88 pub command: String,
89 /// The literal argv as a positional vector, when the call is the
90 /// "list/tuple of string literals, no shell string" shape a `cmd:`
91 /// interceptor can ever match (issue #41) — `None` for a bare-string
92 /// command, a dynamic call, `shell=True`, or a launcher no runtime pack
93 /// currently intercepts at all (`os.system`/`os.popen`; Node's scanner
94 /// doesn't sight the launchers `child-process.mjs` intercepts, so it
95 /// always reports `None` here too, pending its own scanner work). `None`
96 /// means "never a `[flows.match."cmd:*"]` match candidate", independent
97 /// of what `command`'s text happens to look like.
98 pub argv: Option<Vec<String>>,
99}
100
101/// One hand-rolled resilience pattern sighted inside a function that also
102/// reaches a Keel-relevant target — a simplification lead: once the target
103/// is wrapped, the pattern becomes redundant. `kind` is a closed set:
104/// "hand-rolled-retry" | "hand-rolled-poll" | "silent-swallow". `line`
105/// anchors the construct to delete (the loop / the `except` line), not the
106/// sleep call inside it. Python-only as of this build (JS pattern parity is
107/// a spec'd follow-on program).
108#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
109pub struct SimplificationSighting {
110 pub file: String,
111 pub line: u32,
112 pub kind: String,
113 pub function: String,
114 pub targets: Vec<String>,
115}
116
117/// One hand-rolled orchestration construct sighted in a file the language
118/// passes never parse — shell scripts, `Makefile`s, CI workflows. Coarse by
119/// design: a substring line match, not a parse, so it is a *lead* to inspect,
120/// never a verdict. `kind` is a closed set: "lockfile-mutex" | "guard-file" |
121/// "pid-check". Field order is sort order.
122#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
123pub struct OrchestrationSighting {
124 pub file: String,
125 pub line: u32,
126 pub kind: String,
127 pub snippet: String,
128}
129
130/// One file the scan judged dependency-averse: stdlib-only imports plus a
131/// risk/gate/guard/auth/valid/safety/kill name or docstring signal, or an
132/// explicit `# keel: exclude` marker. Markers win in both directions: an
133/// exclude marker forces this classification regardless of imports, and an
134/// include marker defeats the heuristic even where it would otherwise match.
135/// `keel doctor`/`keel init` (a later program task) use this to honestly
136/// exclude hosts seen only in such files from proposed policy. Field order
137/// is the sort order (by file).
138#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
139pub struct DepAverseFile {
140 /// Project-relative path with `/` separators.
141 pub file: String,
142 /// `"marker"` for an explicit `# keel: exclude`, or
143 /// `"stdlib-only + name/docstring signal: <word>"`.
144 pub reason: String,
145}
146
147/// One place a target was seen: a project-relative path and 1-based line.
148#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
149pub struct Sighting {
150 /// Project-relative path with `/` separators.
151 pub file: String,
152 /// 1-based line number.
153 pub line: u32,
154}
155
156impl Sighting {
157 /// Render as the `file:line` token used in evidence comments.
158 pub fn label(&self) -> String {
159 format!("{}:{}", self.file, self.line)
160 }
161}
162
163/// A target and everywhere the static scan saw it, deduplicated and ordered.
164#[derive(Debug, Clone)]
165pub struct TargetEvidence {
166 /// The target's class.
167 pub class: TargetClass,
168 /// Sorted, unique sightings.
169 pub sightings: BTreeSet<Sighting>,
170}
171
172/// Per-function effect attribution — the evidence behind `keel flows suggest`.
173///
174/// Each language pass attributes what it finds *inside* a function definition
175/// to that function: intercepted-effect call sites, calls that read time or
176/// randomness (virtualized under Tier 2 replay), and constructs that defeat
177/// replay outright (threads, subprocesses, raw sockets). Both passes
178/// attribute by real containment: the Python walker via `ast` module-level
179/// def bodies, the JS/TS pass via a real oxc scope walk (see [`js`]) — an
180/// entry opens only for a function bound directly at module top level; class
181/// methods and nested/inner functions roll up into the enclosing top-level
182/// entry rather than opening their own.
183#[derive(Debug, Clone, Default, PartialEq, Eq)]
184pub struct FunctionFacts {
185 /// The full flow-entrypoint ref this function would be designated as —
186 /// `py:pipeline.ingest:main` or `ts:jobs/nightly.ts#run` (the `ts:`
187 /// namespace covers all JS/TS files).
188 pub entrypoint: String,
189 /// Project-relative path of the defining file.
190 pub file: String,
191 /// 1-based line of the `def`/`function`.
192 pub line: u32,
193 /// Intercepted-effect call sites (HTTP / LLM / DSN-bearing libraries).
194 pub effects: u32,
195 /// Effect calls that are not idempotent-safe to re-send (POST/PATCH-shaped)
196 /// and carry no idempotency evidence.
197 pub idempotent_unsafe: u32,
198 /// Wall-clock reads (`time.time`, `datetime.now`, `Date.now`, …) — these
199 /// are virtualized (journaled + replayed) under Tier 2.
200 pub time_reads: u32,
201 /// Randomness reads (`random.*`, `uuid4`, `Math.random`, …) — also
202 /// virtualized under Tier 2.
203 pub random_reads: u32,
204 /// Why replay would be unsafe (empty = the replay-safe estimate holds).
205 /// Each reason cites `what at file:line`; sorted, deterministic.
206 pub unsafe_reasons: Vec<String>,
207 /// Targets referenced inside the function (hosts from URL literals,
208 /// `llm:<provider>` from SDK calls) — the join key into `.keel/discovery.db`.
209 pub targets: BTreeSet<String>,
210}
211
212/// The merged output of both scanners.
213#[derive(Debug, Clone, Default)]
214pub struct ScanResult {
215 /// Number of source files parsed (Python + JS/TS) — the header's "N static
216 /// scans".
217 pub files_scanned: usize,
218 /// Whether `python3` was available for the Python pass. When false, Python
219 /// files could not be scanned; `keel init` notes this on stderr rather than
220 /// letting it silently narrow coverage.
221 pub python_available: bool,
222 /// Discovered targets, keyed by target string, ordered.
223 pub targets: BTreeMap<String, TargetEvidence>,
224 /// Effect-library names detected across the project (e.g. `httpx`,
225 /// `openai`, `boto3`, `fetch`). `keel doctor` cross-references these against
226 /// its adapter registry to classify coverage.
227 pub libs: BTreeSet<String>,
228 /// Per-function attribution (see [`FunctionFacts`]), sorted by
229 /// `(file, line)` — deterministic across runs.
230 pub functions: Vec<FunctionFacts>,
231 /// Known resilience-library names detected, across both languages (see
232 /// [`LangFindings::resilience_libs`]).
233 pub resilience_libs: BTreeSet<String>,
234 /// Best-known [`TransportClass`] per sighted host, merged across every
235 /// file and language that sighted it (minimum = best class wins). Unlike
236 /// `targets`, this is never gated on `http_in_use` — it exists precisely
237 /// to let `keel doctor` report on hosts Keel cannot see at all.
238 pub host_transports: BTreeMap<String, TransportClass>,
239 /// Every externally-launched process the scan saw, sorted by
240 /// `(file, line)` — deterministic across runs. `keel doctor` uses this to
241 /// call out where Keel's visibility ends at a process boundary.
242 pub subprocesses: Vec<SubprocessSighting>,
243 /// Files judged dependency-averse across the project, sorted by file —
244 /// see [`DepAverseFile`]. Python-only as of this build (see
245 /// [`LangFindings::dependency_averse`]).
246 pub dependency_averse: Vec<DepAverseFile>,
247 /// Hand-rolled resilience patterns sighted in functions with target
248 /// attribution, sorted by (file, line, kind) — deterministic across
249 /// runs.
250 pub simplifications: Vec<SimplificationSighting>,
251 /// Hand-rolled orchestration sighted in unparsed shell/Makefile/CI files,
252 /// sorted by (file, line, kind) — deterministic across runs. Coarse
253 /// substring leads, never a parse; `keel doctor` surfaces them as the
254 /// place to look for at-most-once dispatch a `cmd:` flow could replace.
255 pub orchestration: Vec<OrchestrationSighting>,
256}
257
258impl ScanResult {
259 fn add(&mut self, target: String, class: TargetClass, file: String, line: u32) {
260 self.targets
261 .entry(target)
262 .or_insert_with(|| TargetEvidence {
263 class,
264 sightings: BTreeSet::new(),
265 })
266 .sightings
267 .insert(Sighting { file, line });
268 }
269}
270
271/// Scan `project` with both scanners and merge. Host targets are only emitted
272/// when the language pass also saw an HTTP client in use (a bare URL in a
273/// non-networked file is not evidence of an outbound call), keeping the output
274/// honest.
275pub fn scan(project: &Path) -> ScanResult {
276 let mut result = ScanResult::default();
277
278 let py = python::scan(project);
279 result.python_available = py.available;
280 result.files_scanned += py.files_scanned;
281 merge_lang(&mut result, &py.findings);
282 result.functions.extend(py.functions);
283
284 let js = js::scan(project);
285 result.files_scanned += js.files_scanned;
286 merge_lang(&mut result, &js.findings);
287 result.functions.extend(js.functions);
288
289 result.orchestration = scan_orchestration(project);
290
291 result
292 .functions
293 .sort_by(|a, b| (&a.file, a.line, &a.entrypoint).cmp(&(&b.file, b.line, &b.entrypoint)));
294 result.subprocesses.sort();
295 result.dependency_averse.sort();
296 result.simplifications.sort();
297 result
298}
299
300/// One language scanner's raw findings before host-gating.
301#[derive(Debug, Clone, Default)]
302pub struct LangFindings {
303 /// Provider SDK imports → `llm:*` targets.
304 pub llm: Vec<(String, Sighting)>,
305 /// URL/DSN host literals → host targets (gated on `http_in_use`).
306 pub hosts: Vec<(String, Sighting)>,
307 /// Whether an HTTP client (http lib / fetch / undici) was seen at all.
308 pub http_in_use: bool,
309 /// Effect-library names detected (for `keel doctor`'s registry cross-check).
310 pub libs: BTreeSet<String>,
311 /// Effect call sites with enclosing-function attribution.
312 pub call_sites: Vec<CallSite>,
313 /// Known resilience-library names detected (e.g. `tenacity`, `backoff`)
314 /// — a `keel doctor` signal for pre-existing retry/backoff that might
315 /// now silently compound with Keel's own. Deliberately separate from
316 /// `libs`: these are libraries Keel never adapts, so merging them in
317 /// would misclassify them as an "invisible" coverage gap.
318 pub resilience_libs: BTreeSet<String>,
319 /// Per-sighting [`TransportClass`] for every host this language pass saw,
320 /// keyed by host. Never gated on `http_in_use` — a bare URL literal with
321 /// no reachable transport is exactly the `Unknown` case `keel doctor`
322 /// needs to report honestly.
323 pub host_transports: BTreeMap<String, TransportClass>,
324 /// Externally-launched processes this language pass saw (see
325 /// [`SubprocessSighting`]).
326 pub subprocesses: Vec<SubprocessSighting>,
327 /// Files this language pass judged dependency-averse (see
328 /// [`DepAverseFile`]).
329 pub dependency_averse: Vec<DepAverseFile>,
330 /// Hand-rolled resilience patterns this language pass saw (see
331 /// [`SimplificationSighting`]). Python-only as of this build.
332 pub simplifications: Vec<SimplificationSighting>,
333}
334
335fn merge_lang(result: &mut ScanResult, f: &LangFindings) {
336 for (provider, s) in &f.llm {
337 result.add(
338 format!("llm:{provider}"),
339 TargetClass::Llm,
340 s.file.clone(),
341 s.line,
342 );
343 }
344 if f.http_in_use {
345 for (host, s) in &f.hosts {
346 result.add(host.clone(), TargetClass::Host, s.file.clone(), s.line);
347 }
348 }
349 for lib in &f.libs {
350 result.libs.insert(lib.clone());
351 }
352 for lib in &f.resilience_libs {
353 result.resilience_libs.insert(lib.clone());
354 }
355 for (host, class) in &f.host_transports {
356 result
357 .host_transports
358 .entry(host.clone())
359 .and_modify(|c| *c = (*c).min(*class))
360 .or_insert(*class);
361 }
362 result.subprocesses.extend(f.subprocesses.iter().cloned());
363 result
364 .dependency_averse
365 .extend(f.dependency_averse.iter().cloned());
366 result
367 .simplifications
368 .extend(f.simplifications.iter().cloned());
369}
370
371/// Directory names never descended into during a filesystem walk — scans,
372/// `keel init`'s Python-file check, and `keel flows resume`'s module search
373/// all share this one list (previously three drifted copies; see the
374/// 2026-07-14 fast-follow that consolidated them).
375pub(crate) const SKIP_DIRS: &[&str] = &[
376 ".keel",
377 ".git",
378 ".hg",
379 ".svn",
380 "__pycache__",
381 "node_modules",
382 ".venv",
383 "venv",
384 ".mypy_cache",
385 ".pytest_cache",
386 "dist",
387 "build",
388 "target",
389];
390
391/// The one filesystem walker in this crate: recurse from `dir`, skipping
392/// [`SKIP_DIRS`] and dot-prefixed directories except those named in
393/// `descend_dot_dirs`, pushing every file `keep` accepts. [`collect_files`] is
394/// the by-extension front door (JS/TS scan, `keel init`'s Python-file check,
395/// `keel run`'s directory-entry resolution); [`scan_orchestration`] passes its
396/// own predicate and needs `.github`/`.circleci`. One walker so the SKIP_DIRS
397/// logic never drifts into copies again.
398pub(crate) fn collect_matching(
399 dir: &Path,
400 keep: &dyn Fn(&Path) -> bool,
401 descend_dot_dirs: &[&str],
402 out: &mut Vec<PathBuf>,
403) {
404 let Ok(entries) = std::fs::read_dir(dir) else {
405 return;
406 };
407 for entry in entries.flatten() {
408 let path = entry.path();
409 let name = entry.file_name();
410 let name = name.to_string_lossy();
411 if path.is_dir() {
412 if SKIP_DIRS.contains(&name.as_ref())
413 || (name.starts_with('.') && !descend_dot_dirs.contains(&name.as_ref()))
414 {
415 continue;
416 }
417 collect_matching(&path, keep, descend_dot_dirs, out);
418 } else if keep(&path) {
419 out.push(path);
420 }
421 }
422}
423
424/// Recursively collect files under `dir` whose extension is one of
425/// `extensions`, skipping [`SKIP_DIRS`] and dot-prefixed directories.
426pub(crate) fn collect_files(dir: &Path, extensions: &[&str], out: &mut Vec<PathBuf>) {
427 collect_matching(
428 dir,
429 &|p| {
430 p.extension()
431 .and_then(|e| e.to_str())
432 .is_some_and(|e| extensions.contains(&e))
433 },
434 &[],
435 out,
436 );
437}
438
439/// Extensions whose files are always orchestration candidates.
440const ORCH_EXTS: &[&str] = &["sh", "bash", "zsh", "mk"];
441/// Exact file names that are orchestration candidates anywhere in the tree.
442const ORCH_NAMES: &[&str] = &["Makefile", "makefile", "GNUmakefile"];
443/// Project-relative CI files that live outside `.github/workflows/`.
444const ORCH_CI_PATHS: &[&str] = &[".gitlab-ci.yml", ".circleci/config.yml"];
445/// Directories whose extensionless files are treated as shell candidates,
446/// shebang-verified when read. Bounded on purpose: sniffing every extensionless
447/// file in a tree would read LICENSEs, fixtures, and binaries.
448const ORCH_SCRIPT_DIRS: &[&str] = &["bin", "script", "scripts", "tools", "hooks"];
449
450/// True for a file the orchestration pass should read. Deliberately narrow —
451/// see [`scan_orchestration`] for the v1 limits this leaves open.
452fn is_orchestration_file(path: &Path, project: &Path) -> bool {
453 let ext = path.extension().and_then(|e| e.to_str());
454 if ext.is_some_and(|e| ORCH_EXTS.contains(&e)) {
455 return true;
456 }
457 if path
458 .file_name()
459 .and_then(|n| n.to_str())
460 .is_some_and(|n| ORCH_NAMES.contains(&n))
461 {
462 return true;
463 }
464 if ext.is_none()
465 && path
466 .parent()
467 .and_then(Path::file_name)
468 .and_then(|n| n.to_str())
469 .is_some_and(|n| ORCH_SCRIPT_DIRS.contains(&n))
470 {
471 return true; // shebang-verified at read time
472 }
473 let Ok(rel) = path.strip_prefix(project) else {
474 return false;
475 };
476 let rel = rel.to_string_lossy().replace('\\', "/");
477 if ORCH_CI_PATHS.contains(&rel.as_str()) {
478 return true;
479 }
480 rel.starts_with(".github/workflows/") && matches!(ext, Some("yml" | "yaml"))
481}
482
483/// True when `text`'s first line is a shebang naming a POSIX-ish shell.
484fn is_shell_shebang(text: &str) -> bool {
485 text.lines().next().is_some_and(|first| {
486 first.starts_with("#!")
487 && ["sh", "bash", "zsh", "dash", "ksh"]
488 .iter()
489 .any(|s| first.contains(s))
490 })
491}
492
493/// Coarse, dependency-free detection of the at-most-once-dispatch signature in
494/// an unparsed orchestration file. One sighting per matching line; the caller
495/// sorts.
496///
497/// Precision over recall, on purpose: this feeds a `warn` finding, and a
498/// false positive here is exactly the kind [`crate::doctor`]'s
499/// `resilience_finding` argues erodes trust in doctor's real findings. In
500/// particular a file test only counts when it is *guard-shaped* — either the
501/// path looks like a lock/guard/pid/stamp, or the line short-circuits with
502/// `exit 0`. `[ -f .env ] && . .env` and `[ -f "$CFG" ] || exit 1` are ordinary
503/// shell, not at-most-once dispatch, and must stay silent.
504pub(crate) fn scan_orchestration_text(rel: &str, text: &str) -> Vec<OrchestrationSighting> {
505 const FILE_TESTS: &[&str] = &["[ -f ", "[ -e ", "[[ -f ", "[[ -e ", "test -f ", "test -e "];
506 const GUARDISH: &[&str] = &["lock", "guard", ".pid", "stamp", "sentinel", "already"];
507
508 let mut out = Vec::new();
509 for (i, raw) in text.lines().enumerate() {
510 let l = raw.trim_start();
511 if l.starts_with('#') {
512 continue; // shell / yaml / make comment — the shebang included
513 }
514 let lower = l.to_ascii_lowercase();
515 let kind = if lower.contains("flock")
516 || lower.contains("lockfile")
517 || lower.contains("setlock")
518 || (lower.contains("mkdir") && lower.contains("lock"))
519 {
520 Some("lockfile-mutex")
521 } else if lower.contains("kill -0") || lower.contains("kill -s 0") {
522 Some("pid-check")
523 } else if FILE_TESTS.iter().any(|t| l.contains(t))
524 && (GUARDISH.iter().any(|g| lower.contains(g)) || lower.contains("exit 0"))
525 {
526 Some("guard-file")
527 } else {
528 None
529 };
530 if let Some(kind) = kind {
531 out.push(OrchestrationSighting {
532 file: rel.to_owned(),
533 line: u32::try_from(i).unwrap_or(u32::MAX).saturating_add(1),
534 kind: kind.to_owned(),
535 snippet: raw.trim().chars().take(120).collect(),
536 });
537 }
538 }
539 out
540}
541
542/// The orchestration pass: read every file [`is_orchestration_file`] accepts and
543/// collect its coarse leads, sorted by (file, line, kind).
544///
545/// v1 limits, stated honestly because the finding's text promises this reach:
546/// `Jenkinsfile`, `Dockerfile` entrypoint wrappers, crontab files, and CI
547/// systems beyond GitHub Actions / GitLab / CircleCI are not read. Extensionless
548/// files are read only when named an [`ORCH_NAMES`] Makefile variant, or under
549/// [`ORCH_SCRIPT_DIRS`] with a shell shebang. Non-UTF8 and >512 KiB files are
550/// skipped.
551pub(crate) fn scan_orchestration(project: &Path) -> Vec<OrchestrationSighting> {
552 const MAX_BYTES: u64 = 512 * 1024;
553
554 let mut files = Vec::new();
555 collect_matching(
556 project,
557 &|p| is_orchestration_file(p, project),
558 &[".github", ".circleci"],
559 &mut files,
560 );
561 files.sort();
562
563 let mut out = Vec::new();
564 for f in files {
565 if std::fs::metadata(&f).is_ok_and(|m| m.len() > MAX_BYTES) {
566 continue;
567 }
568 let Ok(text) = std::fs::read_to_string(&f) else {
569 continue; // non-UTF8 or unreadable — not a script we can read
570 };
571 // The shebang gate is for ORCH_SCRIPT_DIRS' extensionless candidates
572 // only — an extensionless file already accepted by exact name (a
573 // Makefile variant) is unambiguous and needs no shebang check.
574 let is_named_makefile = f
575 .file_name()
576 .and_then(|n| n.to_str())
577 .is_some_and(|n| ORCH_NAMES.contains(&n));
578 if f.extension().is_none() && !is_named_makefile && !is_shell_shebang(&text) {
579 continue;
580 }
581 let rel = f
582 .strip_prefix(project)
583 .unwrap_or(&f)
584 .to_string_lossy()
585 .replace('\\', "/");
586 out.extend(scan_orchestration_text(&rel, &text));
587 }
588 out.sort();
589 out
590}
591
592/// Extract the host from a `scheme://host[:port][/…]` literal, lowercased and
593/// without port/userinfo/path. Returns `None` for non-URL strings. Shared by
594/// both scanners so Python and JS agree on what a host is.
595pub(crate) fn host_from_url(s: &str) -> Option<String> {
596 let s = s.trim();
597 let (scheme, rest) = s.split_once("://")?;
598 if scheme.is_empty()
599 || !scheme
600 .chars()
601 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
602 || !scheme
603 .chars()
604 .next()
605 .is_some_and(|c| c.is_ascii_alphabetic())
606 {
607 return None;
608 }
609 // authority ends at the first '/', '?', or '#'.
610 let authority = rest.split(['/', '?', '#']).next().unwrap_or(rest);
611 // strip userinfo, then port.
612 let host_port = authority.rsplit('@').next().unwrap_or(authority);
613 let host = host_port.split(':').next().unwrap_or(host_port);
614 if host.is_empty() || host.contains(|c: char| c.is_whitespace()) {
615 return None;
616 }
617 Some(host.to_ascii_lowercase())
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use std::fs;
624 use tempfile::TempDir;
625
626 #[test]
627 fn host_extraction_strips_port_userinfo_and_path() {
628 assert_eq!(
629 host_from_url("https://api.stripe.com/v1/x").as_deref(),
630 Some("api.stripe.com")
631 );
632 assert_eq!(
633 host_from_url("postgres://u:p@db.internal:5432/app").as_deref(),
634 Some("db.internal")
635 );
636 assert_eq!(host_from_url("HTTPS://API.X").as_deref(), Some("api.x"));
637 assert_eq!(host_from_url("not a url"), None);
638 assert_eq!(host_from_url("://nohost"), None);
639 assert_eq!(host_from_url("1bad://x"), None);
640 }
641
642 #[test]
643 fn orchestration_text_flags_the_at_most_once_signature() {
644 let text = "\
645#!/usr/bin/env bash
646set -euo pipefail
647flock -n /tmp/run.lock || exit 0
648if [ -f /var/run/autonomous.guard ]; then exit 0; fi
649kill -0 \"$PID\" 2>/dev/null && echo running
650echo work
651";
652 let hits = scan_orchestration_text("scripts/run_autonomous.sh", text);
653 let kinds: BTreeSet<&str> = hits.iter().map(|h| h.kind.as_str()).collect();
654 assert!(kinds.contains("lockfile-mutex"), "kinds: {kinds:?}");
655 assert!(kinds.contains("guard-file"), "kinds: {kinds:?}");
656 assert!(kinds.contains("pid-check"), "kinds: {kinds:?}");
657 // Line anchoring: flock is on line 3 (the shebang is line 1).
658 let lock = hits.iter().find(|h| h.kind == "lockfile-mutex").unwrap();
659 assert_eq!(lock.line, 3, "flock line");
660 }
661
662 /// The precision test that matters: ordinary shell must stay silent. Every
663 /// line here contains a file test or a lock-ish word and none of them is
664 /// at-most-once dispatch. A `warn` finding on any of these would be the
665 /// false positive `resilience_finding` argues erodes trust in real findings.
666 #[test]
667 fn orchestration_text_is_quiet_on_ordinary_scripts() {
668 let text = "\
669#!/bin/sh
670echo hello
671cp a b
672[ -f .env ] && . .env
673test -f target/release/keel || cargo build
674[ -f \"$CONFIG\" ] || exit 1
675if [ -e node_modules ]; then echo deps; fi
676npm ci --package-lock-only
677";
678 let hits = scan_orchestration_text("build.sh", text);
679 assert!(hits.is_empty(), "false positives: {hits:?}");
680 }
681
682 /// Comments never count — including the shebang.
683 #[test]
684 fn orchestration_text_skips_comments() {
685 let text = "# flock -n /tmp/x.lock || exit 0\n\t# kill -0 $PID\n";
686 assert!(scan_orchestration_text("Makefile", text).is_empty());
687 }
688
689 #[test]
690 fn scan_sights_shell_makefile_and_ci_orchestrators_end_to_end() {
691 let dir = TempDir::new().unwrap();
692 fs::create_dir_all(dir.path().join("scripts")).unwrap();
693 fs::write(
694 dir.path().join("scripts/run_autonomous.sh"),
695 "#!/bin/bash\nflock -n /tmp/x.lock || exit 0\n",
696 )
697 .unwrap();
698 fs::write(
699 dir.path().join("Makefile"),
700 "deploy:\n\tkill -0 $$(cat run.pid) && exit 0\n",
701 )
702 .unwrap();
703 fs::create_dir_all(dir.path().join(".github/workflows")).unwrap();
704 fs::write(
705 dir.path().join(".github/workflows/cron.yml"),
706 "jobs:\n x:\n steps:\n - run: flock -n /tmp/ci.lock -c ./deploy.sh\n",
707 )
708 .unwrap();
709
710 let scan = scan(dir.path());
711 let files: BTreeSet<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
712 assert!(files.contains("scripts/run_autonomous.sh"), "{files:?}");
713 assert!(files.contains("Makefile"), "{files:?}");
714 assert!(files.contains(".github/workflows/cron.yml"), "{files:?}");
715 // Sorted by (file, line, kind) — the doctor finding's dedup relies on it.
716 let mut sorted = scan.orchestration.clone();
717 sorted.sort();
718 assert_eq!(sorted, scan.orchestration);
719 }
720
721 /// Extensionless `bin/`-style scripts count only when the shebang says so —
722 /// otherwise the walk would read every LICENSE and README in the tree.
723 #[test]
724 fn extensionless_scripts_need_a_shell_shebang() {
725 let dir = TempDir::new().unwrap();
726 fs::create_dir_all(dir.path().join("bin")).unwrap();
727 fs::write(
728 dir.path().join("bin/deploy"),
729 "#!/bin/sh\nflock -n /tmp/d.lock\n",
730 )
731 .unwrap();
732 fs::write(dir.path().join("bin/NOTES"), "flock is used here\n").unwrap();
733 let scan = scan(dir.path());
734 let files: BTreeSet<&str> = scan.orchestration.iter().map(|o| o.file.as_str()).collect();
735 assert!(files.contains("bin/deploy"), "{files:?}");
736 assert!(!files.contains("bin/NOTES"), "{files:?}");
737 }
738
739 #[test]
740 fn orchestration_walk_skips_vendored_and_build_dirs() {
741 let dir = TempDir::new().unwrap();
742 for d in ["node_modules", "target", ".venv"] {
743 fs::create_dir_all(dir.path().join(d)).unwrap();
744 fs::write(dir.path().join(d).join("x.sh"), "flock -n /tmp/x.lock\n").unwrap();
745 }
746 assert!(scan(dir.path()).orchestration.is_empty());
747 }
748}