@@ -715,6 +715,12 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
715715 let mut diags = Vec::new();
716716 // (name, span) of every field bound by an OUTER layer, in chain order.
717717 let mut outer: HashMap<String, Span> = HashMap::new();
718+ // `#`-argument fields seen across the chain: name → (type sig text, the
719+ // default's literal display if it is a scalar literal). CLI args resolve
720+ // globally by name, so a repeated `#name` is NOT shadowing — every
721+ // declaration refers to the same value — as long as they all agree on type
722+ // and default. Tracked separately so an agreeing repeat is allowed.
723+ let mut cli_args: HashMap<String, (Option<String>, Option<String>, Span)> = HashMap::new();
718724 let mut cur = program;
719725 loop {
720726 match &cur.node {
@@ -723,6 +729,12 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
723729 // repeated across two outer layers shadows within the chain.
724730 if let ExprKind::Record(fields) = &record.node {
725731 for f in fields {
732+ // A CLI `#arg` is not a shadowing candidate; check it
733+ // against prior `#arg` decls instead.
734+ if let ExprKind::ArgDecl { .. } = &f.value.node {
735+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
736+ continue;
737+ }
726738 // Absent keys have no name, so they can never shadow.
727739 let Some(fname) = f.name.as_name() else { continue };
728740 if outer.insert(fname.to_string(), f.value.span).is_some() {
@@ -742,6 +754,10 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
742754 // Its fields must not repeat any outer layer's name.
743755 if let ExprKind::Record(fields) = &record.node {
744756 for f in fields {
757+ if let ExprKind::ArgDecl { .. } = &f.value.node {
758+ check_cli_arg_agreement(f, &mut cli_args, &mut diags);
759+ continue;
760+ }
745761 let Some(fname) = f.name.as_name() else { continue };
746762 if outer.contains_key(fname) {
747763 diags.push(
@@ -762,6 +778,110 @@ pub fn check_with_chain_shadowing(program: &Expr) -> Vec<knot::diagnostic::Diagn
762778 diags
763779}
764780
781+/// Record a CLI `#arg` declaration and check it against prior declarations of
782+/// the same name across the `with` chain. A single declaration is always fine
783+/// (any default). A REPEAT declaration must agree with the earlier one on type
784+/// (the sig) and default — and since agreement must be checkable, a repeated
785+/// arg's default must be a literal or `_`. Two concrete defaults must be equal
786+/// literals; a concrete default may pair with any number of `_` (required)
787+/// declarations, in which case the concrete one supplies the value.
788+fn check_cli_arg_agreement(
789+ f: &knot::ast::RecordField,
790+ cli_args: &mut HashMap<String, (Option<String>, Option<String>, Span)>,
791+ diags: &mut Vec<knot::diagnostic::Diagnostic>,
792+) {
793+ use knot::ast::ExprKind;
794+ let ExprKind::ArgDecl { name, default } = &f.value.node else {
795+ return;
796+ };
797+ // The declared type (the `Type #name` sig), rendered for comparison.
798+ let ty_str = f
799+ .sig
800+ .as_ref()
801+ .map(|s| knot::format::render_type(&s.ty));
802+ // The default, as a literal display (`None` if `_` or non-literal).
803+ let default_str = default
804+ .as_ref()
805+ .and_then(|d| default_literal_display(d));
806+
807+ match cli_args.get(name) {
808+ None => {
809+ cli_args.insert(name.clone(), (ty_str, default_str, f.value.span));
810+ }
811+ Some((prev_ty, prev_default, prev_span)) => {
812+ // This is a REPEAT declaration of the same `#name`. Since every
813+ // declaration must resolve to the same value, agreement is required
814+ // — and it must be CHECKABLE, so a repeated arg needs a literal (or
815+ // `_`) default on every occurrence.
816+ if default.is_some() && default_str.is_none() {
817+ diags.push(
818+ knot::diagnostic::Diagnostic::error(format!(
819+ "argument `#{name}` is declared in multiple scopes, so its default must be a literal (or `_`) here",
820+ ))
821+ .label(f.value.span, "computed defaults can't be compared across scopes"),
822+ );
823+ return;
824+ }
825+ // Types must agree.
826+ if *prev_ty != ty_str {
827+ diags.push(
828+ knot::diagnostic::Diagnostic::error(format!(
829+ "argument `#{name}` is declared with different types",
830+ ))
831+ .label(f.value.span, "this declaration")
832+ .label(*prev_span, "conflicts with this earlier declaration"),
833+ );
834+ return;
835+ }
836+ // Defaults must agree: equal literals, or the `_`-exception (one
837+ // concrete default among `_` declarations).
838+ let defaults_ok = match (prev_default, &default_str) {
839+ // Both `_`: fine.
840+ (None, None) => true,
841+ // One concrete, one `_`: fine (the concrete one supplies the value).
842+ (Some(_), None) | (None, Some(_)) => true,
843+ // Two concrete: must be the same literal.
844+ (Some(a), Some(b)) => a == b,
845+ };
846+ if !defaults_ok {
847+ diags.push(
848+ knot::diagnostic::Diagnostic::error(format!(
849+ "argument `#{name}` is declared with different defaults",
850+ ))
851+ .label(f.value.span, "this declaration")
852+ .label(*prev_span, "conflicts with this earlier declaration"),
853+ );
854+ return;
855+ }
856+ // Keep the concrete default (if any) as the canonical one.
857+ let merged = prev_default.clone().or(default_str);
858+ cli_args.insert(name.clone(), (ty_str, merged, *prev_span));
859+ }
860+ }
861+}
862+
863+/// Render a default-value expression as a literal display string, or `None` if
864+/// it isn't a scalar literal (Int/Float/Text/Bool/Maybe thereof). Mirrors the
865+/// CLI default rendering used at codegen.
866+fn default_literal_display(expr: &knot::ast::Expr) -> Option<String> {
867+ use knot::ast::ExprKind;
868+ match &expr.node {
869+ ExprKind::Lit(lit) => match lit {
870+ knot::ast::Literal::Int(s) => Some(s.clone()),
871+ knot::ast::Literal::Float(f) => Some(format!("{}", f)),
872+ knot::ast::Literal::Text(s) => {
873+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"");
874+ Some(format!("\"{}\"", escaped))
875+ }
876+ },
877+ ExprKind::UnaryOp {
878+ op: knot::ast::UnaryOp::Neg,
879+ operand,
880+ } => default_literal_display(operand).map(|s| format!("-{}", s)),
881+ _ => None,
882+ }
883+}
884+
765885/// Collect the alias names referenced anywhere inside a type AST.
766886fn collect_named_alias_refs(ty: &Type, alias_names: &HashSet<String>, out: &mut HashSet<String>) {
767887 match &ty.node {