diff --git a/.github/workflows/ast-grep.yml b/.github/workflows/ast-grep.yml
new file mode 100644
--- /dev/null
+++ b/.github/workflows/ast-grep.yml
@@ -0,0 +1,26 @@
+# Structural rules for the command-line code (RFC-0012): lint/ast-grep's rule tests, then a scan.
+# Seconds, no Rust build.
+name: ast-grep
+
+on:
+  push:
+    branches: [main]
+    paths: ["lint/ast-grep/**", "apps/photocraft-cli/**", "crates/automation/src/args.rs", "apps/photocraft/src/launch_args.rs", "apps/photocraft/src/main.rs", ".github/workflows/ast-grep.yml"]
+  pull_request:
+    paths: ["lint/ast-grep/**", "apps/photocraft-cli/**", "crates/automation/src/args.rs", "apps/photocraft/src/launch_args.rs", "apps/photocraft/src/main.rs", ".github/workflows/ast-grep.yml"]
+  workflow_dispatch:
+
+permissions:
+  contents: read
+
+concurrency:
+  group: ast-grep-${{ github.ref }}
+  cancel-in-progress: true
+
+jobs:
+  scan:
+    # Tracked at @main like the other shared workflows.
+    uses: storytold/craft-common-actions/.github/workflows/ast-grep.yml@main
+    with:
+      config: lint/ast-grep/sgconfig.yml
+      paths: apps/photocraft-cli crates/automation/src/args.rs apps/photocraft/src/launch_args.rs apps/photocraft/src/main.rs
diff --git a/crates/automation/src/args.rs b/crates/automation/src/args.rs
--- a/crates/automation/src/args.rs
+++ b/crates/automation/src/args.rs
@@ -99,8 +99,16 @@ pub fn conflict(cmd: &mut Command, m: &ArgMatches, a: &str, b: &str) -> Result<(
             _ => arg.map_or_else(|| id.to_owned(), |arg| arg.to_string()),
         }
     };
-    let message = format!("the argument '{}' cannot be used with '{}'", shown(a), shown(b));
-    Err(cmd.error(ErrorKind::ArgumentConflict, message))
+    let both = Conflict { a: shown(a), b: shown(b) };
+    Err(cmd.error(ErrorKind::ArgumentConflict, both))
+}
+
+/// A [`conflict`]: two arguments, each named as it was given (the flag, or its variable).
+#[derive(Debug, thiserror::Error)]
+#[error("the argument '{a}' cannot be used with '{b}'")]
+struct Conflict {
+    a: String,
+    b: String,
 }
 
 #[cfg(test)]
diff --git a/lint/ast-grep/rule-tests/args-os-test.yml b/lint/ast-grep/rule-tests/args-os-test.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rule-tests/args-os-test.yml
@@ -0,0 +1,19 @@
+id: args-os
+valid:
+  - let code = photocraft_cli::run(std::env::args_os().skip(1), &mut out, &mut err);
+  - "let args: Vec<OsString> = env::args_os().collect();"
+  - |
+    use std::env::args_os;
+    fn main() { let a: Vec<_> = args_os().collect(); }
+  - |
+    fn args() -> Vec<String> { Vec::new() }
+    fn main() { let a = args(); }
+invalid:
+  - "let args: Vec<String> = std::env::args().collect();"
+  - for a in env::args().skip(1) {}
+  - |
+    use std::env::args;
+    fn main() { let a: Vec<String> = args().collect(); }
+  - |
+    use std::env::{args, var};
+    fn main() { for a in args() {} }
diff --git a/lint/ast-grep/rule-tests/clap-error-message-test.yml b/lint/ast-grep/rule-tests/clap-error-message-test.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rule-tests/clap-error-message-test.yml
@@ -0,0 +1,13 @@
+id: clap-error-message
+valid:
+  - cmd.error(ErrorKind::ArgumentConflict, UsageError::BridgeRoots)
+  - cmd.error(ErrorKind::ArgumentConflict, Conflict { a, b })
+  - sub.error(kind, self)
+  - log.error("not a clap error")
+invalid:
+  - cmd.error(ErrorKind::ArgumentConflict, "configure automation roots on the desktop app")
+  - sub.error(ErrorKind::MissingRequiredArgument, format!("{flag} requires {transport}"))
+  - sub.error(ErrorKind::ArgumentConflict, BRIDGE_ROOTS)
+  - cmd.error(ErrorKind::InvalidValue, e.to_string())
+  - cmd.error(ErrorKind::InvalidValue, String::from("bad value"))
+  - clap::Error::raw(ErrorKind::InvalidValue, "bad value")
diff --git a/lint/ast-grep/rule-tests/clap-error-message-variable-test.yml b/lint/ast-grep/rule-tests/clap-error-message-variable-test.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rule-tests/clap-error-message-variable-test.yml
@@ -0,0 +1,47 @@
+id: clap-error-message-variable
+valid:
+  - |
+    fn conflict(cmd: &mut Command, a: String, b: String) -> clap::Error {
+        cmd.error(ErrorKind::ArgumentConflict, Conflict { a, b })
+    }
+  - |
+    fn wrap(cmd: &mut Command, e: UsageError) -> clap::Error {
+        cmd.error(ErrorKind::ValueValidation, e)
+    }
+  - |
+    fn unrelated(cmd: &mut Command, e: UsageError) -> clap::Error {
+        let shown = format!("{e}");
+        eprintln!("{shown}");
+        cmd.error(ErrorKind::ValueValidation, e)
+    }
+  - |
+    fn cloned(cmd: &mut Command, e: &UsageError) -> clap::Error {
+        let owned = e.to_owned();
+        cmd.error(ErrorKind::ValueValidation, owned)
+    }
+invalid:
+  - |
+    fn conflict(cmd: &mut Command, a: &str, b: &str) -> clap::Error {
+        let message = format!("the argument '{a}' cannot be used with '{b}'");
+        cmd.error(ErrorKind::ArgumentConflict, message)
+    }
+  - |
+    fn bad(cmd: &mut Command, e: UsageError) -> clap::Error {
+        let message = e.to_string();
+        cmd.error(ErrorKind::ValueValidation, message)
+    }
+  - |
+    fn typed(cmd: &mut Command, flag: &str) -> clap::Error {
+        let message: String = flag.into();
+        clap::Error::raw(ErrorKind::InvalidValue, message)
+    }
+  - |
+    fn literal(cmd: &mut Command) -> clap::Error {
+        let message = "bad value".to_owned();
+        cmd.error(ErrorKind::InvalidValue, message)
+    }
+  - |
+    fn from(cmd: &mut Command) -> clap::Error {
+        let message = String::from("bad value");
+        cmd.error(ErrorKind::InvalidValue, message)
+    }
diff --git a/lint/ast-grep/rule-tests/string-error-test.yml b/lint/ast-grep/rule-tests/string-error-test.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rule-tests/string-error-test.yml
@@ -0,0 +1,14 @@
+id: string-error
+valid:
+  - "fn parse(s: &str) -> Result<Port, PortError> { todo!() }"
+  - "fn name() -> Option<String> { None }"
+  - "fn ok() -> Result<String, PortError> { Ok(String::new()) }"
+  - Err(PortError::Range)
+  - Err(e)
+invalid:
+  - "fn parse(s: &str) -> Result<Port, String> { todo!() }"
+  - "type R = Result<(), String>;"
+  - Err(format!("bad port {s}"))
+  - Err(e.to_string())
+  - Err(String::from("bad port"))
+  - Err("bad port")
diff --git a/lint/ast-grep/rules/args-os.yml b/lint/ast-grep/rules/args-os.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rules/args-os.yml
@@ -0,0 +1,21 @@
+id: args-os
+language: rust
+severity: error
+message: std::env::args() panics on an argument that isn't valid Unicode
+note: |
+  Read std::env::args_os() and let clap parse the OsStrings, so a Latin-1 file name reaches the
+  command (or is a usage error) instead of aborting before anything is printed (#1108).
+rule:
+  any:
+    - pattern: std::env::args()
+    - pattern: env::args()
+    # A bare args() only where the file imports std::env::args, not any function named args.
+    - all:
+        - pattern: args()
+        - inside:
+            kind: source_file
+            stopBy: end
+            has:
+              kind: use_declaration
+              stopBy: end
+              regex: std::env::(args\b|\{[^}]*\bargs\b)
diff --git a/lint/ast-grep/rules/clap-error-message-variable.yml b/lint/ast-grep/rules/clap-error-message-variable.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rules/clap-error-message-variable.yml
@@ -0,0 +1,35 @@
+id: clap-error-message-variable
+language: rust
+severity: error
+message: clap error built from a string held in a variable
+note: |
+  The message was built with format!, .to_string(), String::from or a literal's .to_owned() a
+  few lines up. Give it a variant on a
+  #[derive(thiserror::Error)] enum instead, with the parts as fields, and pass the variant.
+rule:
+  # In this order: the call binds $MSG, then the function must hold a `let` of that same name.
+  all:
+    - any:
+        - pattern: $CMD.error($KIND, $MSG)
+        - pattern: Error::raw($KIND, $MSG)
+        - pattern: clap::Error::raw($KIND, $MSG)
+    - inside:
+        kind: function_item
+        stopBy: end
+        has:
+          stopBy: end
+          any:
+            - pattern: let $MSG = format!($$$);
+            - pattern: "let $MSG: String = $VAL;"
+            - pattern: let $MSG = $SRC.to_string();
+            - pattern: let $MSG = String::from($$$);
+            # .to_owned() only on a string literal: on anything else it may clone a typed error.
+            - all:
+                - kind: let_declaration
+                - regex: '=\s*"(?s:.*)"\s*\.to_owned\(\)\s*;?$'
+                - has:
+                    field: pattern
+                    pattern: $MSG
+constraints:
+  MSG:
+    kind: identifier
diff --git a/lint/ast-grep/rules/clap-error-message.yml b/lint/ast-grep/rules/clap-error-message.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rules/clap-error-message.yml
@@ -0,0 +1,26 @@
+id: clap-error-message
+language: rust
+severity: error
+message: clap error built from a string
+note: |
+  Give the message a variant on a #[derive(thiserror::Error)] enum and pass the variant, e.g.
+  `cmd.error(ErrorKind::ArgumentConflict, UsageError::BridgeRoots)`. A string literal, format!,
+  .to_string() or a SCREAMING_CASE const keeps the message apart from the error's type.
+rule:
+  any:
+    - pattern: $CMD.error($KIND, $MSG)
+    - pattern: Error::raw($KIND, $MSG)
+    - pattern: clap::Error::raw($KIND, $MSG)
+constraints:
+  MSG:
+    any:
+      - kind: string_literal
+      - all:
+          - kind: macro_invocation
+          - regex: ^format!
+      - all:
+          - kind: identifier
+          - regex: ^[A-Z][A-Z0-9_]*$
+      - all:
+          - kind: call_expression
+          - regex: \.(to_string|to_owned)\(\)$|^String::from\(
diff --git a/lint/ast-grep/rules/string-error.yml b/lint/ast-grep/rules/string-error.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/rules/string-error.yml
@@ -0,0 +1,21 @@
+id: string-error
+language: rust
+severity: error
+message: String as an error type
+note: |
+  Return a #[derive(thiserror::Error)] enum, so a caller can match on what failed and the
+  messages live with their variants.
+rule:
+  any:
+    - pattern:
+        context: type T = Result<$OK, String>;
+        selector: generic_type
+    - pattern: Err(format!($$$))
+    - pattern: Err($S.to_string())
+    - pattern: Err(String::from($$$))
+    - all:
+        - pattern: Err($S)
+        - has:
+            kind: arguments
+            has:
+              kind: string_literal
diff --git a/lint/ast-grep/sgconfig.yml b/lint/ast-grep/sgconfig.yml
new file mode 100644
--- /dev/null
+++ b/lint/ast-grep/sgconfig.yml
@@ -0,0 +1,6 @@
+# Structural rules for the command-line code (RFC-0012), run by .github/workflows/ast-grep.yml.
+# Each rule in rules/ has its cases in rule-tests/, which CI checks before the scan.
+ruleDirs:
+  - rules
+testConfigs:
+  - testDir: rule-tests
