Skip to main content

bash_ast/
summary.rs

1//! High-level summary extracted from a parsed bash [`Program`].
2//!
3//! [`summarise_bash_shape`] is the single public entry point. It walks the AST
4//! once and returns a [`BashShape`] that the desktop host attaches to each
5//! `Bash` tool-call frame as `args._shape`. The TS side reads this for the
6//! ActivityReport bucket bar — no second parse, no full AST on the wire.
7//!
8//! @yah:ticket(R196-T13, "BashShape.primary_program: skip pure-assignment stmts, recurse into compound bodies, unwrap assign=$(cmd)")
9//! @yah:at(2026-05-16T02:42:10Z)
10//! @yah:status(review)
11//! @yah:parent(R196)
12//! @yah:severity(P2)
13//! @yah:next("Repro: noisetable session:a5025082 — 10-iter loop where ./tests/run_replay_tests.sh is buried in for/do/done > out=$(VAR=1 ./tests/run_replay_tests.sh 27 2>&1). Three statement shapes the current 'strip leading cd && / VAR=val' heuristic misses.")
14//! @yah:next("Peel 1: pure-assignment stmts (pass=0; fail=0) — skip them as primary, same way leading var=val inside a command is skipped.")
15//! @yah:next("Peel 2: compound stmts (for/while/if/{…}) — recurse into the body; the load-bearing program is inside do…done, not the loop keyword.")
16//! @yah:next("Peel 3: assign-from-cmdsub (out=$(VAR=1 cmd …)) — when the stmt is only an assignment whose RHS is one command substitution, unwrap and take the inner command as primary. Same idea as 'cd X && cmd'.")
17//! @yah:next("Edit extract_primary / extract_primary_stmt at summary.rs:431. side_effect + kind keep deriving off the unwrapped primary; cwd_hint stays untouched. all_programs unchanged.")
18//! @yah:verify("cargo test -p bash-ast (add fixture mirroring the noisetable shape under tests/summary.rs — assert primary_program == \"./tests/run_replay_tests.sh\")")
19//! @yah:verify("cargo test -p agent-tools --lib approval:: still 96/96 (no schema change)")
20//! @yah:verify("bun run typecheck (no TS change expected — BashShape wire format unchanged)")
21//! @arch:see(.yah/docs/working/W055-agent-approval-evolution.md)
22//! @yah:handoff("extract_primary refactored into extract_from_stmt_list() helper (R196-T13). Three peels: (1) VariableAssignment/VariableAssignments skipped (pure assignments); (2) For/While/If/CompoundStatement recurse into body via extract_from_stmt_list; (3) VariableAssignment where RHS is a CommandSubstitution unwraps to the inner body. loop_body_stmts() helper flattens LoopBody. 7 new tests in tests/summary.rs: peel1_pure_assignment_stmts_skipped, peel2_for_loop_body_recursed, peel2_while_loop_body_recursed, peel2_compound_body_recursed, peel3_assign_from_cmdsub_unwrapped, noisetable_repro_for_loop_with_assign_cmdsub. Also fixed pre-existing AgentSubclass missing caches:true in form_tools.rs (3x) and arch_tools.rs (1x).")
23//! @yah:verify("cargo test -p bash-ast # 62/62 including 7 new peel tests")
24//! @yah:verify("cargo test -p agent-tools --lib approval:: # 156/156 (no schema change)")
25//! @yah:verify("cargo check --workspace # clean")
26
27use serde::{Deserialize, Serialize};
28
29use crate::ast::{
30    Command, CommandArgument, CommandNameInner, ListOperator,
31    PrimaryExpression, Program, Redirect, RedirectedStatement, Span,
32    Statement, StringPart,
33};
34
35// ============================================================================
36// Public types
37// ============================================================================
38
39/// Compact summary of a bash command string, attached to `args._shape` on
40/// each normalised `Bash` tool-call frame. All fields are optional/additive:
41/// readers must ignore unknown keys, and missing keys mean "not computed"
42/// (older sessions, parse failure).
43#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
44#[serde(rename_all = "camelCase")]
45pub struct BashShape {
46    /// First command's program name after stripping a leading `cd X &&` prefix
47    /// and any leading `var=val` assignments — what the operator was actually
48    /// trying to run.
49    pub primary_program: Option<String>,
50    /// All program names found in the script, in invocation order, deduped.
51    pub all_programs: Vec<String>,
52    /// Top-level list operators present (`"&&"`, `"||"`), deduped.
53    pub list_ops: Vec<String>,
54    /// Maximum pipeline stage count seen (1 = no pipe, >1 = piped).
55    pub pipeline_stages: usize,
56    /// File-redirect operators (`>`, `>>`, `&>`, …) used anywhere in the script.
57    pub redirects: Vec<String>,
58    /// `true` when a `<<EOF` heredoc opens — UI can collapse the body.
59    pub has_heredoc: bool,
60    /// Working-directory hint: argument of a leading `cd` that was stripped
61    /// when determining `primary_program`. Rendered as "(in foo/bar)".
62    pub cwd_hint: Option<String>,
63    /// Side-effect classification derived from `primary_program` + first argument.
64    /// `None` = read-only or unknown.
65    pub side_effect: Option<SideEffect>,
66    /// Coarse program category derived from `primary_program` alone. Lets the
67    /// UI pick an icon / activity bucket without re-mapping a hardcoded program
68    /// list in TS. Orthogonal to `side_effect` — `kind` is *what the program is*,
69    /// `side_effect` is *what this invocation does*.
70    pub kind: Option<ProgramKind>,
71    /// Source byte ranges of each top-level statement in `program.statements`,
72    /// in order. Use this — never `raw.split('\n')` — when a consumer needs to
73    /// segment a multi-statement bash string back into its individual commands
74    /// (e.g. the conveyor modal's batch-approve UI). `\<LF>` line-continuations
75    /// are collapsed at the lexer level by tree-sitter, so a backslash-continued
76    /// multi-flag invocation yields ONE slice, not one per `\<LF>`.
77    #[serde(default)]
78    pub top_level_statements: Vec<StatementSlice>,
79}
80
81/// Byte range + coarse kind of one top-level statement in the parsed program.
82/// Stable for cross-language consumers (the desktop ships this on the wire to
83/// the UI). Byte indices are into the original source string.
84#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
85#[serde(rename_all = "camelCase")]
86pub struct StatementSlice {
87    pub byte_start: usize,
88    pub byte_end: usize,
89    pub kind: StatementKind,
90}
91
92/// Coarse tag mirroring [`crate::ast::Statement`] variants. Lets downstream
93/// consumers gate on statement shape (e.g. "skip splitting if any slice is a
94/// Pipeline / List / If / For") without re-walking the full AST.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96pub enum StatementKind {
97    Command,
98    Pipeline,
99    List,
100    Compound,
101    Subshell,
102    If,
103    While,
104    For,
105    CStyleFor,
106    Case,
107    FunctionDefinition,
108    Redirected,
109    Declaration,
110    Unset,
111    Test,
112    Negated,
113    VariableAssignment,
114    VariableAssignments,
115}
116
117/// Rule-based side-effect classification. Intentionally narrow — expand only
118/// with evidence; the classifier never fires on read-only siblings.
119#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
120pub enum SideEffect {
121    /// `git push`, `git commit`, `git tag`, `git merge`, `git rebase`,
122    /// `git reset`, `git branch`, `git checkout`, `git clean`
123    GitWrite,
124    /// `gh pr create/merge/close`, `gh release create`, `gh issue create`
125    GitHub,
126    /// `cargo publish`, `npm publish`, `bun publish`
127    Publish,
128    /// `rm`, `rmdir`, `dd`, `mkfs*`
129    Destructive,
130    /// `curl`/`wget` with `-X POST`/`PUT`/`DELETE`/`PATCH`
131    Network,
132    /// `sudo *`, `brew install`, `apt install`, `apt-get install`
133    SudoOrInstall,
134}
135
136/// Coarse category for a primary program. Stable identifier used downstream
137/// for icon selection, activity bucketing, and permission heuristics.
138///
139/// Adding a kind is cheap; removing or renaming one is a breaking change for
140/// every consumer (the TS mirror, `kindToGlyph`, the activity report). Prefer
141/// extending [`PROGRAM_KIND_TABLE`] over inventing new variants.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143pub enum ProgramKind {
144    /// Read-only inspection / search: `ls`, `grep`, `rg`, `find`, `cat`,
145    /// `head`, `tail`, `wc`, `file`, `stat`, `du`, `awk`, `sed`, `jq`.
146    Search,
147    /// Version control: `git`.
148    Vcs,
149    /// Build / package tooling: `cargo`, `npm`, `bun`, `pnpm`, `yarn`,
150    /// `make`, `rustc`.
151    Build,
152    /// Network clients: `curl`, `wget`, `gh`, `http`.
153    Network,
154    /// Containers: `docker`, `podman`, `kubectl`.
155    Container,
156    /// Remote shells / copy: `ssh`, `scp`, `rsync`.
157    Remote,
158    /// Privilege elevation: `sudo`, `su`.
159    Elevation,
160    /// Destructive filesystem / process ops: `rm`, `rmdir`, `mv`, `kill`,
161    /// `pkill`, `shutdown`, `reboot`, `dd`.
162    Destructive,
163    /// Scripting runtimes: `python`, `python3`, `node`, `ruby`, `perl`,
164    /// `deno`, `lua`, `php`.
165    ScriptingRuntime,
166    /// The yah CLI itself.
167    Yah,
168    /// Model Context Protocol servers / clients. Matched by exact name *or*
169    /// by substring on the program name (`mcp-server-foo`, `foo-mcp`) when
170    /// the exact-match table misses — see [`kind_for`].
171    Mcp,
172}
173
174/// Lookup table: program name → kind. Authoritative exact-match list for
175/// [`kind_for`]. Substring fallbacks (e.g. `mcp` → [`ProgramKind::Mcp`]) live
176/// in `kind_for` itself.
177const PROGRAM_KIND_TABLE: &[(&str, ProgramKind)] = &[
178    // Search / inspect — file/text/process introspection
179    ("ls", ProgramKind::Search),
180    ("grep", ProgramKind::Search),
181    ("rg", ProgramKind::Search),
182    ("ag", ProgramKind::Search),
183    ("ack", ProgramKind::Search),
184    ("find", ProgramKind::Search),
185    ("fd", ProgramKind::Search),
186    ("cat", ProgramKind::Search),
187    ("bat", ProgramKind::Search),
188    ("head", ProgramKind::Search),
189    ("tail", ProgramKind::Search),
190    ("less", ProgramKind::Search),
191    ("more", ProgramKind::Search),
192    ("wc", ProgramKind::Search),
193    ("file", ProgramKind::Search),
194    ("stat", ProgramKind::Search),
195    ("du", ProgramKind::Search),
196    ("df", ProgramKind::Search),
197    ("tree", ProgramKind::Search),
198    ("awk", ProgramKind::Search),
199    ("sed", ProgramKind::Search),
200    ("jq", ProgramKind::Search),
201    ("yq", ProgramKind::Search),
202    ("which", ProgramKind::Search),
203    ("whereis", ProgramKind::Search),
204    ("pwd", ProgramKind::Search),
205    ("echo", ProgramKind::Search),
206    ("printf", ProgramKind::Search),
207    ("fzf", ProgramKind::Search),
208    ("ps", ProgramKind::Search),
209    ("top", ProgramKind::Search),
210    ("htop", ProgramKind::Search),
211    ("lsof", ProgramKind::Search),
212    ("diff", ProgramKind::Search),
213    // VCS
214    ("git", ProgramKind::Vcs),
215    ("hg", ProgramKind::Vcs),
216    ("svn", ProgramKind::Vcs),
217    ("jj", ProgramKind::Vcs),
218    // Build / package
219    ("cargo", ProgramKind::Build),
220    ("rustc", ProgramKind::Build),
221    ("rustup", ProgramKind::Build),
222    ("npm", ProgramKind::Build),
223    ("bun", ProgramKind::Build),
224    ("pnpm", ProgramKind::Build),
225    ("yarn", ProgramKind::Build),
226    ("make", ProgramKind::Build),
227    ("cmake", ProgramKind::Build),
228    ("ninja", ProgramKind::Build),
229    ("bazel", ProgramKind::Build),
230    ("tsc", ProgramKind::Build),
231    ("cc", ProgramKind::Build),
232    ("gcc", ProgramKind::Build),
233    ("clang", ProgramKind::Build),
234    ("go", ProgramKind::Build),
235    ("dotnet", ProgramKind::Build),
236    ("swift", ProgramKind::Build),
237    ("maven", ProgramKind::Build),
238    ("gradle", ProgramKind::Build),
239    // Network — clients + probes
240    ("curl", ProgramKind::Network),
241    ("wget", ProgramKind::Network),
242    ("gh", ProgramKind::Network),
243    ("http", ProgramKind::Network),
244    ("httpie", ProgramKind::Network),
245    ("ping", ProgramKind::Network),
246    ("dig", ProgramKind::Network),
247    ("nslookup", ProgramKind::Network),
248    ("host", ProgramKind::Network),
249    ("traceroute", ProgramKind::Network),
250    ("nc", ProgramKind::Network),
251    ("ncat", ProgramKind::Network),
252    ("netcat", ProgramKind::Network),
253    ("openssl", ProgramKind::Network),
254    // Container / orchestration
255    ("docker", ProgramKind::Container),
256    ("podman", ProgramKind::Container),
257    ("nerdctl", ProgramKind::Container),
258    ("buildah", ProgramKind::Container),
259    ("kubectl", ProgramKind::Container),
260    ("helm", ProgramKind::Container),
261    ("terraform", ProgramKind::Container),
262    ("ansible", ProgramKind::Container),
263    // Remote
264    ("ssh", ProgramKind::Remote),
265    ("scp", ProgramKind::Remote),
266    ("rsync", ProgramKind::Remote),
267    ("mosh", ProgramKind::Remote),
268    ("sftp", ProgramKind::Remote),
269    // Elevation
270    ("sudo", ProgramKind::Elevation),
271    ("su", ProgramKind::Elevation),
272    ("doas", ProgramKind::Elevation),
273    // Destructive
274    ("rm", ProgramKind::Destructive),
275    ("rmdir", ProgramKind::Destructive),
276    ("mv", ProgramKind::Destructive),
277    ("kill", ProgramKind::Destructive),
278    ("pkill", ProgramKind::Destructive),
279    ("shutdown", ProgramKind::Destructive),
280    ("reboot", ProgramKind::Destructive),
281    ("halt", ProgramKind::Destructive),
282    ("dd", ProgramKind::Destructive),
283    ("shred", ProgramKind::Destructive),
284    ("truncate", ProgramKind::Destructive),
285    // Scripting runtimes
286    ("python", ProgramKind::ScriptingRuntime),
287    ("python3", ProgramKind::ScriptingRuntime),
288    ("node", ProgramKind::ScriptingRuntime),
289    ("deno", ProgramKind::ScriptingRuntime),
290    ("ruby", ProgramKind::ScriptingRuntime),
291    ("perl", ProgramKind::ScriptingRuntime),
292    ("lua", ProgramKind::ScriptingRuntime),
293    ("php", ProgramKind::ScriptingRuntime),
294    // yah
295    ("yah", ProgramKind::Yah),
296];
297
298/// Map a primary program name to its [`ProgramKind`], or `None` for unknown
299/// commands.
300///
301/// Resolution order:
302/// 1. Exact match in [`PROGRAM_KIND_TABLE`].
303/// 2. Substring fallback for "yah" (case-insensitive) — `yah-yubaba`,
304///    `yah-camp`, `yah-image-mcp` etc. all classify as [`ProgramKind::Yah`].
305///    Wins over the mcp fallback below: a yah binary that happens to speak
306///    MCP is still a yah binary.
307/// 3. Substring fallback for "mcp" (case-insensitive) — `mcp-server-foo`,
308///    `claude-mcp`, etc. classify as [`ProgramKind::Mcp`].
309///
310/// Consumed by the summariser and exposed for downstream tools (permission
311/// rules, indexers) that want the same classification.
312pub fn kind_for(program: &str) -> Option<ProgramKind> {
313    if let Some(kind) = PROGRAM_KIND_TABLE
314        .iter()
315        .find_map(|(name, kind)| (*name == program).then_some(*kind))
316    {
317        return Some(kind);
318    }
319    // ASCII-only lowercase compare — fast, no allocation in the common path.
320    // Yah wins ties because a yah-* binary that speaks MCP is still a yah one.
321    if contains_ignore_ascii_case(program, "yah") {
322        return Some(ProgramKind::Yah);
323    }
324    if contains_ignore_ascii_case(program, "mcp") {
325        return Some(ProgramKind::Mcp);
326    }
327    None
328}
329
330fn contains_ignore_ascii_case(haystack: &str, needle: &str) -> bool {
331    if needle.is_empty() || needle.len() > haystack.len() {
332        return haystack.is_empty() == needle.is_empty();
333    }
334    haystack
335        .as_bytes()
336        .windows(needle.len())
337        .any(|w| w.eq_ignore_ascii_case(needle.as_bytes()))
338}
339
340// ============================================================================
341// Entry point
342// ============================================================================
343
344/// Walk `program` once and extract a [`BashShape`] summary.
345pub fn summarise_bash_shape(program: &Program) -> BashShape {
346    let mut all_programs: Vec<String> = Vec::new();
347    let mut list_ops_set: Vec<String> = Vec::new();
348    let mut pipeline_stages: usize = 1;
349    let mut redirects: Vec<String> = Vec::new();
350    let mut has_heredoc = false;
351
352    for stmt in &program.statements {
353        collect_info(
354            stmt,
355            &mut all_programs,
356            &mut list_ops_set,
357            &mut pipeline_stages,
358            &mut redirects,
359            &mut has_heredoc,
360        );
361    }
362
363    // Dedup while preserving insertion order.
364    dedup_vec(&mut all_programs);
365    dedup_vec(&mut list_ops_set);
366
367    // Extract primary program, cwd hint, and side-effect from the first
368    // meaningful top-level statement.
369    let (primary_program, cwd_hint, side_effect) = extract_primary(program);
370    // Kind is computed from the *effective* program — primary_program with
371    // wrappers (timeout/env/nohup/…) peeled. `peel_command` parses the
372    // wrapper's argv to find the wrapped command (`timeout 30 git push`
373    // → "git"), which `all_programs` alone can't recover because wrapped
374    // commands live as arguments, not separate Command nodes. Falls back
375    // to primary_program when the script isn't a single-command shape
376    // (pipelines, `cd && cmd`, etc.) — peel_command rejects those.
377    let kind = crate::wrappers::peel_command(program)
378        .map(|p| p.primary)
379        .or_else(|| primary_program.clone())
380        .as_deref()
381        .and_then(kind_for);
382
383    let top_level_statements: Vec<StatementSlice> = program
384        .statements
385        .iter()
386        .map(statement_slice)
387        .collect();
388
389    BashShape {
390        primary_program,
391        all_programs,
392        list_ops: list_ops_set,
393        pipeline_stages,
394        redirects,
395        has_heredoc,
396        cwd_hint,
397        side_effect,
398        kind,
399        top_level_statements,
400    }
401}
402
403/// Lift a top-level [`Statement`] into a [`StatementSlice`]. Every statement
404/// variant carries a `span: Span`; tagging the kind here lets the UI gate on
405/// statement shape without a second AST walk.
406fn statement_slice(stmt: &Statement) -> StatementSlice {
407    let (span, kind) = match stmt {
408        Statement::Command(s) => (s.span, StatementKind::Command),
409        Statement::Pipeline(s) => (s.span, StatementKind::Pipeline),
410        Statement::List(s) => (s.span, StatementKind::List),
411        Statement::CompoundStatement(s) => (s.span, StatementKind::Compound),
412        Statement::Subshell(s) => (s.span, StatementKind::Subshell),
413        Statement::If(s) => (s.span, StatementKind::If),
414        Statement::While(s) => (s.span, StatementKind::While),
415        Statement::For(s) => (s.span, StatementKind::For),
416        Statement::CStyleFor(s) => (s.span, StatementKind::CStyleFor),
417        Statement::Case(s) => (s.span, StatementKind::Case),
418        Statement::FunctionDefinition(s) => (s.span, StatementKind::FunctionDefinition),
419        Statement::Redirected(s) => (s.span, StatementKind::Redirected),
420        Statement::Declaration(s) => (s.span, StatementKind::Declaration),
421        Statement::Unset(s) => (s.span, StatementKind::Unset),
422        Statement::Test(s) => (s.span, StatementKind::Test),
423        Statement::Negated(s) => (s.span, StatementKind::Negated),
424        Statement::VariableAssignment(s) => (s.span, StatementKind::VariableAssignment),
425        Statement::VariableAssignments(s) => (s.span, StatementKind::VariableAssignments),
426    };
427    let Span { byte_start, byte_end, .. } = span;
428    StatementSlice { byte_start, byte_end, kind }
429}
430
431// ============================================================================
432// Whole-program collector (populates all_programs / list_ops / etc.)
433// ============================================================================
434
435fn collect_info(
436    stmt: &Statement,
437    all_programs: &mut Vec<String>,
438    list_ops: &mut Vec<String>,
439    pipeline_stages: &mut usize,
440    redirects: &mut Vec<String>,
441    has_heredoc: &mut bool,
442) {
443    match stmt {
444        Statement::Command(cmd) => {
445            if let Some(name) = cmd_name_text(cmd) {
446                all_programs.push(name);
447            }
448        }
449        Statement::Pipeline(p) => {
450            *pipeline_stages = (*pipeline_stages).max(p.stages.len());
451            for stage in &p.stages {
452                collect_info(stage, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
453            }
454        }
455        Statement::List(list) => {
456            let op: &str = match list.operator {
457                ListOperator::And => "&&",
458                ListOperator::Or => "||",
459            };
460            list_ops.push(op.to_owned());
461            collect_info(&list.left, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
462            collect_info(&list.right, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
463        }
464        Statement::Redirected(r) => {
465            collect_redirects(r, redirects, has_heredoc);
466            if let Some(body) = &r.body {
467                collect_info(body, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
468            }
469        }
470        Statement::CompoundStatement(cs) => {
471            for s in &cs.statements {
472                collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
473            }
474        }
475        Statement::Negated(n) => {
476            collect_info(&n.inner, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
477        }
478        Statement::If(i) => {
479            for s in &i.body {
480                collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
481            }
482            for elif in &i.elif_clauses {
483                for s in &elif.body {
484                    collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
485                }
486            }
487            if let Some(else_cl) = &i.else_clause {
488                for s in &else_cl.body {
489                    collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
490                }
491            }
492        }
493        Statement::While(w) => {
494            use crate::ast::LoopBody;
495            match &w.body {
496                LoopBody::Compound(cs) => {
497                    for s in &cs.statements {
498                        collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
499                    }
500                }
501                LoopBody::DoGroup(dg) => {
502                    for s in &dg.statements {
503                        collect_info(s, all_programs, list_ops, pipeline_stages, redirects, has_heredoc);
504                    }
505                }
506            }
507        }
508        // Declaration / unset / test / assignment — not runnable programs.
509        _ => {}
510    }
511}
512
513fn collect_redirects(r: &RedirectedStatement, redirects: &mut Vec<String>, has_heredoc: &mut bool) {
514    for redirect in &r.redirects {
515        match redirect {
516            Redirect::File(f) => redirects.push(f.operator.clone()),
517            Redirect::Heredoc(_) => *has_heredoc = true,
518            Redirect::Herestring(_) => {}
519        }
520    }
521}
522
523// ============================================================================
524// Primary-command extractor
525// ============================================================================
526
527/// Returns `(primary_program, cwd_hint, side_effect)` from the first
528/// meaningful statement in the program.
529fn extract_primary(program: &Program) -> (Option<String>, Option<String>, Option<SideEffect>) {
530    extract_from_stmt_list(&program.statements, None)
531}
532
533/// Walk a statement list to find the first "real" command (R196-T13).
534///
535/// Three peeling rules applied in order per statement:
536///
537/// - **Peel 1** — pure-assignment statements (`pass=0`, `fail=0`) are
538///   skipped; they carry no program name.
539/// - **Peel 3** — an assignment whose sole RHS is a command substitution
540///   (`out=$(VAR=1 cmd …)`) is unwrapped: the inner substitution body
541///   becomes the new statement list to scan.
542/// - Everything else routes to [`extract_primary_stmt`], which applies
543///   Peel 2 (recurse into compound bodies) and the existing `cd &&`
544///   threading logic.
545fn extract_from_stmt_list(
546    stmts: &[Statement],
547    inherited_cwd: Option<String>,
548) -> (Option<String>, Option<String>, Option<SideEffect>) {
549    for stmt in stmts {
550        match stmt {
551            // Peel 3: assignment whose RHS is a single command substitution.
552            Statement::VariableAssignment(a) => {
553                if let crate::ast::AssignmentValue::Primary(
554                    crate::ast::PrimaryExpression::CommandSubstitution(cs),
555                ) = &a.value
556                {
557                    let result = extract_from_stmt_list(&cs.body, inherited_cwd.clone());
558                    if result.0.is_some() {
559                        return result;
560                    }
561                }
562                // Peel 1: pure assignment (or cmdsub with no identifiable
563                // primary) — skip and continue.
564            }
565            // Peel 1: multi-assignment (`a=1 b=2`) with no command.
566            Statement::VariableAssignments(_) => {}
567            _ => {
568                let result = extract_primary_stmt(stmt, inherited_cwd.clone());
569                if result.0.is_some() {
570                    return result;
571                }
572                // If extract_primary_stmt returned None primary (e.g. bare
573                // `cd /foo`), keep the updated cwd for the next iteration but
574                // don't return — continue scanning.
575            }
576        }
577    }
578    (None, inherited_cwd, None)
579}
580
581/// Recursively walk `stmt` to find the first "real" command, threading through
582/// `cd X &&` prefixes to produce `cwd_hint`.
583///
584/// **Peel 2** (R196-T13): compound statements (`for`/`while`/`if`/`{…}`)
585/// recurse into their body via [`extract_from_stmt_list`] so a loop whose
586/// body contains the load-bearing command yields that command as primary.
587///
588/// Returns `(primary_program, cwd_hint, side_effect)`.
589fn extract_primary_stmt(
590    stmt: &Statement,
591    inherited_cwd: Option<String>,
592) -> (Option<String>, Option<String>, Option<SideEffect>) {
593    match stmt {
594        Statement::Command(cmd) => {
595            let prog = cmd_name_text(cmd);
596            if prog.as_deref() == Some("cd") {
597                // Leading cd — capture path and signal "no primary yet".
598                let cwd = cmd.arguments.first().and_then(arg_text).or(inherited_cwd);
599                return (None, cwd, None);
600            }
601            let first_arg = cmd.arguments.first().and_then(arg_text);
602            let side = classify(prog.as_deref(), first_arg.as_deref(), &cmd.arguments);
603            (prog, inherited_cwd, side)
604        }
605        Statement::List(list) => {
606            let (lprog, lcwd, lside) = extract_primary_stmt(&list.left, inherited_cwd.clone());
607            if lprog.is_none() {
608                // Left was a cd or empty — continue on the right with captured cwd.
609                extract_primary_stmt(&list.right, lcwd)
610            } else {
611                (lprog, lcwd, lside)
612            }
613        }
614        Statement::Pipeline(p) => match p.stages.first() {
615            Some(first) => extract_primary_stmt(first, inherited_cwd),
616            None => (None, inherited_cwd, None),
617        },
618        Statement::Redirected(r) => match &r.body {
619            Some(body) => extract_primary_stmt(body, inherited_cwd),
620            None => (None, inherited_cwd, None),
621        },
622        Statement::Negated(n) => extract_primary_stmt(&n.inner, inherited_cwd),
623        // Peel 2: compound bodies — recurse into the first meaningful statement.
624        Statement::For(f) => {
625            let stmts = loop_body_stmts(&f.body);
626            extract_from_stmt_list(stmts, inherited_cwd)
627        }
628        Statement::While(w) => {
629            let stmts = loop_body_stmts(&w.body);
630            extract_from_stmt_list(stmts, inherited_cwd)
631        }
632        Statement::CompoundStatement(cs) => {
633            extract_from_stmt_list(&cs.statements, inherited_cwd)
634        }
635        Statement::If(i) => extract_from_stmt_list(&i.body, inherited_cwd),
636        _ => (None, inherited_cwd, None),
637    }
638}
639
640/// Flatten a [`LoopBody`] to its contained statement slice.
641fn loop_body_stmts(body: &crate::ast::LoopBody) -> &[Statement] {
642    match body {
643        crate::ast::LoopBody::Compound(cs) => &cs.statements,
644        crate::ast::LoopBody::DoGroup(dg) => &dg.statements,
645    }
646}
647
648// ============================================================================
649// Side-effect classifier
650// ============================================================================
651
652fn classify(prog: Option<&str>, first_arg: Option<&str>, args: &[CommandArgument]) -> Option<SideEffect> {
653    let prog = prog?;
654    let fa = first_arg.unwrap_or("");
655
656    const GIT_WRITE: &[&str] = &[
657        "push", "commit", "tag", "merge", "rebase",
658        "reset", "branch", "checkout", "clean",
659    ];
660    const GH_OBJECTS: &[&str] = &["pr", "release", "issue"];
661    const GH_WRITE_ACTIONS: &[&str] = &["create", "merge", "close"];
662    const HTTP_WRITE_METHODS: &[&str] = &["POST", "PUT", "DELETE", "PATCH"];
663
664    match prog {
665        "git" => {
666            if GIT_WRITE.contains(&fa) {
667                return Some(SideEffect::GitWrite);
668            }
669        }
670        "gh" => {
671            if GH_OBJECTS.contains(&fa) {
672                let second = args.get(1).and_then(arg_text);
673                if second.as_deref().map(|s| GH_WRITE_ACTIONS.contains(&s)).unwrap_or(false) {
674                    return Some(SideEffect::GitHub);
675                }
676            }
677        }
678        "cargo" | "npm" | "bun" => {
679            if fa == "publish" {
680                return Some(SideEffect::Publish);
681            }
682        }
683        "rm" | "rmdir" | "dd" => return Some(SideEffect::Destructive),
684        "curl" | "wget" => {
685            let texts: Vec<Option<String>> = args.iter().map(arg_text).collect();
686            // -X POST  (two tokens)
687            let two_token = texts.windows(2).any(|w| {
688                w[0].as_deref() == Some("-X")
689                    && w[1].as_deref().map(|s| HTTP_WRITE_METHODS.contains(&s)).unwrap_or(false)
690            });
691            // -XPOST  (merged single token)
692            let merged = texts.iter().any(|t| {
693                t.as_deref()
694                    .map(|s| s.starts_with("-X") && HTTP_WRITE_METHODS.contains(&&s[2..]))
695                    .unwrap_or(false)
696            });
697            if two_token || merged {
698                return Some(SideEffect::Network);
699            }
700        }
701        "sudo" => return Some(SideEffect::SudoOrInstall),
702        "brew" => {
703            if fa == "install" {
704                return Some(SideEffect::SudoOrInstall);
705            }
706        }
707        "apt" | "apt-get" => {
708            if fa == "install" {
709                return Some(SideEffect::SudoOrInstall);
710            }
711        }
712        _ => {
713            if prog.starts_with("mkfs") {
714                return Some(SideEffect::Destructive);
715            }
716        }
717    }
718    None
719}
720
721// ============================================================================
722// Text-extraction helpers
723// ============================================================================
724
725fn cmd_name_text(cmd: &Command) -> Option<String> {
726    match &cmd.name.inner {
727        CommandNameInner::Primary(p) => primary_text(p),
728        CommandNameInner::Concatenation(c) => {
729            let s: String = c.parts.iter().filter_map(primary_text).collect();
730            if s.is_empty() { None } else { Some(s) }
731        }
732    }
733}
734
735fn arg_text(arg: &CommandArgument) -> Option<String> {
736    match arg {
737        CommandArgument::Primary(p) => primary_text(p),
738        CommandArgument::Concatenation(c) => {
739            let s: String = c.parts.iter().filter_map(primary_text).collect();
740            if s.is_empty() { None } else { Some(s) }
741        }
742        CommandArgument::Regex(r) => Some(r.text.clone()),
743        CommandArgument::Operator { text, .. } => Some(text.clone()),
744    }
745}
746
747fn primary_text(p: &PrimaryExpression) -> Option<String> {
748    match p {
749        PrimaryExpression::Word(w) => Some(w.text.clone()),
750        PrimaryExpression::RawString(r) => Some(r.text.trim_matches('\'').to_owned()),
751        PrimaryExpression::StringNode(s) => {
752            let text: String = s.parts.iter().filter_map(|part| match part {
753                StringPart::Content(c) => Some(c.text.clone()),
754                _ => None,
755            }).collect();
756            if text.is_empty() { None } else { Some(text) }
757        }
758        PrimaryExpression::AnsiCString(a) => Some(a.text.clone()),
759        PrimaryExpression::SimpleExpansion(_)
760        | PrimaryExpression::Expansion(_)
761        | PrimaryExpression::CommandSubstitution(_)
762        | PrimaryExpression::ArithmeticExpansion(_)
763        | PrimaryExpression::BraceExpression(_)
764        | PrimaryExpression::Number(_)
765        | PrimaryExpression::ProcessSubstitution(_)
766        | PrimaryExpression::TranslatedString(_) => None,
767    }
768}
769
770// ============================================================================
771// Utility
772// ============================================================================
773
774fn dedup_vec(v: &mut Vec<String>) {
775    let mut seen = std::collections::HashSet::new();
776    v.retain(|s| seen.insert(s.clone()));
777}
778