Skip to main content

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}