* [PATCH 2/5] kconfig: add kconfirm
2026-07-27 0:16 [PATCH v4 0/5] add kconfirm Julian Braha
2026-07-27 0:16 ` [PATCH v4 1/5] kconfig: add add another callback to the parser to view raw parse tree Julian Braha
@ 2026-07-27 0:16 ` Julian Braha
2026-07-27 0:16 ` [PATCH v4 3/5] kconfirm: add tests Julian Braha
` (3 subsequent siblings)
5 siblings, 0 replies; 9+ messages in thread
From: Julian Braha @ 2026-07-27 0:16 UTC (permalink / raw)
To: nathan, nsc
Cc: ojeda, akpm, jani.nikula, gary, gregkh, arnd, ljs, andrew.jones,
masahiroy, corbet, qingfang.deng, demiobenour, ej, linux-kernel,
rust-for-linux, linux-doc, linux-kbuild, Julian Braha
Add kconfirm into scripts/kconfig/
kconfirm is a static analysis tool with various checks for Kconfig, and
intended to have zero false alarms by default, though some are expected
for host compiler-related config options due to macro expansion. The
default checks currently include dead code, constant conditions, and
invalid (reverse) ranges.
There are also optional checks for dead links in the help texts, and for
config options that select visible config options.
kconfirm runs semantic analysis on the unsimplified parse tree that the
Kconfig parser provides. Config option definitions are collected into its
symbol table, and checks run from there using informed heuristics. The
dead link check uses the curl CLI to check the liveness of links, and is
optional.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Julian Braha <julianbraha@gmail.com>
---
Makefile | 17 +-
scripts/kconfig/.gitignore | 1 +
scripts/kconfig/Makefile | 54 ++
scripts/kconfig/kconfig.rs | 445 ++++++++++++++
scripts/kconfig/kconfirm/.gitignore | 2 +
scripts/kconfig/kconfirm/analyze.rs | 340 +++++++++++
scripts/kconfig/kconfirm/arch.rs | 53 ++
scripts/kconfig/kconfirm/checks.rs | 748 +++++++++++++++++++++++
scripts/kconfig/kconfirm/dead_links.rs | 230 +++++++
scripts/kconfig/kconfirm/kconfirm-cfg.sh | 57 ++
scripts/kconfig/kconfirm/kconfirm.rs | 278 +++++++++
scripts/kconfig/kconfirm/output.rs | 87 +++
scripts/kconfig/kconfirm/symbol_table.rs | 105 ++++
13 files changed, 2415 insertions(+), 2 deletions(-)
create mode 100644 scripts/kconfig/kconfig.rs
create mode 100644 scripts/kconfig/kconfirm/.gitignore
create mode 100644 scripts/kconfig/kconfirm/analyze.rs
create mode 100644 scripts/kconfig/kconfirm/arch.rs
create mode 100644 scripts/kconfig/kconfirm/checks.rs
create mode 100644 scripts/kconfig/kconfirm/dead_links.rs
create mode 100755 scripts/kconfig/kconfirm/kconfirm-cfg.sh
create mode 100644 scripts/kconfig/kconfirm/kconfirm.rs
create mode 100644 scripts/kconfig/kconfirm/output.rs
create mode 100644 scripts/kconfig/kconfirm/symbol_table.rs
diff --git a/Makefile b/Makefile
index b568bfe8f3ec..e036af320486 100644
--- a/Makefile
+++ b/Makefile
@@ -298,7 +298,7 @@ no-dot-config-targets := $(clean-targets) \
%asm-generic kernelversion %src-pkg dt_binding_check \
dt_style_selftest \
outputmakefile rustavailable rustfmt rustfmtcheck \
- run-command
+ run-command kconfirm kconfirmtest
no-sync-config-targets := $(no-dot-config-targets) %install modules_sign kernelrelease \
image_name
single-targets := %.a %.i %.ko %.lds %.ll %.lst %.mod %.o %.rsi %.s %/
@@ -1879,6 +1879,7 @@ help:
@echo ' headerdep - Detect inclusion cycles in headers'
@echo ' coccicheck - Check with Coccinelle'
@echo ' kconfig-sym-check - Check for dangling Kconfig symbol references'
+ @echo ' kconfirm - Run static analysis on the Kconfig tree'
@echo ' clang-analyzer - Check with clang static analyzer'
@echo ' clang-tidy - Check with clang-tidy'
@echo ''
@@ -2328,7 +2329,7 @@ endif
# Scripts to check various things for consistency
# ---------------------------------------------------------------------------
-PHONY += includecheck versioncheck coccicheck kconfig-sym-check
+PHONY += includecheck versioncheck coccicheck kconfig-sym-check kconfirm kconfirmtest
includecheck:
find $(srctree)/* $(RCS_FIND_IGNORE) \
@@ -2346,6 +2347,18 @@ coccicheck:
kconfig-sym-check:
$(Q)$(PERL) $(srctree)/scripts/kconfig/kconfig-sym-check.pl $(srctree) $(KCONFIG_SYM_CHECK_EXCLUDES)
+kconfirm-hostrustc = $(if $(filter 1,$(KBUILD_CLIPPY)),HOSTRUSTC=$(CLIPPY_DRIVER))
+
+kconfirm: export CC_VERSION_TEXT := $(CC_VERSION_TEXT)
+kconfirm: export RUSTC_VERSION_TEXT := $(RUSTC_VERSION_TEXT)
+kconfirm: export PAHOLE_VERSION := $(PAHOLE_VERSION)
+kconfirm: outputmakefile scripts_basic
+ $(Q)$(MAKE) $(build)=scripts/kconfig $(kconfirm-hostrustc) kconfirm || \
+ (printf "\n kconfirm failed to build or run. It is built with the Rust\n toolchain (rustc, bindgen). See Documentation/dev-tools/kconfirm.rst\n\n" && false)
+
+kconfirmtest: outputmakefile scripts_basic
+ $(Q)$(MAKE) $(build)=scripts/kconfig $(kconfirm-hostrustc) kconfirmtest
+
PHONY += checkstack kernelrelease kernelversion image_name
# UML needs a little special treatment here. It wants to use the host
diff --git a/scripts/kconfig/.gitignore b/scripts/kconfig/.gitignore
index 0b2ff775b2e3..0c2f9763029c 100644
--- a/scripts/kconfig/.gitignore
+++ b/scripts/kconfig/.gitignore
@@ -4,4 +4,5 @@
/[gmnq]conf-bin
/[gmnq]conf-cflags
/[gmnq]conf-libs
+/kconfig_bindings.rs
/qconf-moc.cc
diff --git a/scripts/kconfig/Makefile b/scripts/kconfig/Makefile
index 5baf1c44ffa2..ffdf36a21d79 100644
--- a/scripts/kconfig/Makefile
+++ b/scripts/kconfig/Makefile
@@ -154,6 +154,7 @@ help:
@echo ' default value without prompting'
@echo ' tinyconfig - Configure the tiniest possible kernel'
@echo ' testconfig - Run Kconfig unit tests (requires python3 and pytest)'
+ @echo ' kconfirmtest - Run kconfirm tests (requires python3 and pytest)'
@echo ''
@echo 'Configuration topic targets:'
@$(foreach f, $(all-config-fragments), \
@@ -234,3 +235,56 @@ $(obj)/%conf-cflags $(obj)/%conf-libs $(obj)/%conf-bin: $(src)/%conf-cfg.sh
$(call cmd,conf_cfg)
clean-files += *conf-cflags *conf-libs *conf-bin
+
+# kconfirm: analyzes Kconfig using the un-simplified parse tree.
+hostprogs += kconfirm/kconfirm
+kconfirm/kconfirm-rust := y
+kconfirm/kconfirm-objs := $(common-objs)
+targets += kconfig_bindings.rs
+
+HOSTRUSTFLAGS_kconfirm/kconfirm := \
+ $(addprefix -Clink-arg=$(obj)/,$(kconfirm/kconfirm-objs))
+KCONFIG_BINDINGS := $(abspath $(obj)/kconfig_bindings.rs)
+export KCONFIG_BINDINGS
+
+# The Rust bindings are generated from the parser's C headers.
+quiet_cmd_kconfig_bindgen = BINDGEN $@
+ cmd_kconfig_bindgen = \
+ $(BINDGEN) $< --rust-target 1.85 \
+ --no-doc-comments --no-prepend-enum-name \
+ --allowlist-function 'conf_parse' \
+ --allowlist-function 'conf_set_pre_finalize_callback' \
+ --allowlist-function 'expr_print' \
+ --allowlist-type 'expr' --allowlist-type 'menu' \
+ --allowlist-type 'property' --allowlist-type 'symbol' \
+ --allowlist-type 'expr_type' --allowlist-type 'menu_type' \
+ --allowlist-type 'prop_type' --allowlist-type 'symbol_type' \
+ -o $@ -- -I $(src) -I $(srctree)/scripts/include
+
+$(obj)/kconfig_bindings.rs: $(src)/lkc.h $(src)/expr.h $(src)/lkc_proto.h \
+ $(srctree)/scripts/include/list_types.h FORCE
+ $(call if_changed,kconfig_bindgen)
+
+$(obj)/kconfirm/kconfirm: $(obj)/kconfig_bindings.rs \
+ $(addprefix $(obj)/,$(kconfirm/kconfirm-objs))
+
+# Alert the user when the Rust toolchain is missing or too old.
+PHONY += kconfirm-tool-check
+kconfirm-tool-check:
+ $(Q)$(CONFIG_SHELL) $(src)/kconfirm/kconfirm-cfg.sh
+
+$(obj)/kconfig_bindings.rs: | kconfirm-tool-check
+$(obj)/kconfirm/kconfirm: | kconfirm-tool-check
+
+PHONY += kconfirm
+kconfirm: $(obj)/kconfirm/kconfirm
+ $(Q)$< --linux-path $(abspath $(srctree)) --kconfig $(Kconfig) \
+ $(KCONFIRM_ARGS)
+
+PHONY += kconfirmtest
+kconfirmtest: $(obj)/kconfirm/kconfirm
+ $(Q)$(PYTHON3) -B -m pytest $(src)/kconfirm/tests \
+ --kconfirm $(abspath $<) \
+ -o cache_dir=$(abspath $(obj)/kconfirm/tests/.cache) \
+ $(if $(findstring 1,$(KBUILD_VERBOSE)),--capture=no)
+clean-files += kconfirm/tests/.cache
diff --git a/scripts/kconfig/kconfig.rs b/scripts/kconfig/kconfig.rs
new file mode 100644
index 000000000000..2b27f3c83a51
--- /dev/null
+++ b/scripts/kconfig/kconfig.rs
@@ -0,0 +1,445 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+//! Rust abstraction over the Kconfig parser and its bindings. Only covers
+//! the unsimplified parse tree for now; useful for static analyzers, such as
+//! kconfirm.
+//!
+//! The `raw` module below is generated by bindgen from the parser's C headers.
+use std::{
+ ffi::CStr,
+ fmt,
+ os::raw::{
+ c_char,
+ c_int,
+ c_void, //
+ },
+ panic::{
+ catch_unwind,
+ AssertUnwindSafe, //
+ },
+ ptr, //
+};
+
+#[allow(
+ clippy::all,
+ dead_code,
+ non_camel_case_types,
+ non_upper_case_globals,
+ unreachable_pub
+)]
+mod raw {
+ include!(env!("KCONFIG_BINDINGS"));
+}
+
+use raw::*;
+
+type Expr = expr;
+type Menu = menu;
+type Symbol = symbol;
+
+#[derive(Clone, Debug, PartialEq, Eq, Hash)]
+pub(crate) struct Expression {
+ rendered: String,
+ negation: String,
+}
+
+impl Expression {
+ pub(crate) fn is_negation_of(&self, other: &Self) -> bool {
+ self.negation == other.rendered
+ }
+
+ fn constant(value: &str, negation: &str) -> Self {
+ Self {
+ rendered: value.to_string(),
+ negation: negation.to_string(),
+ }
+ }
+}
+
+impl fmt::Display for Expression {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.rendered.fmt(formatter)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct DefaultAttribute {
+ pub(crate) expression: Expression,
+ pub(crate) r#if: Option<Expression>,
+}
+
+impl fmt::Display for DefaultAttribute {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.expression.fmt(formatter)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct Select {
+ pub(crate) symbol: String,
+ pub(crate) r#if: Option<Expression>,
+}
+
+impl fmt::Display for Select {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.symbol.fmt(formatter)
+ }
+}
+
+pub(crate) type Imply = Select;
+
+#[derive(Clone, Debug)]
+pub(crate) struct RangeBound {
+ pub(crate) value: String,
+}
+
+impl fmt::Display for RangeBound {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ self.value.fmt(formatter)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct Range {
+ pub(crate) lower_bound: RangeBound,
+ pub(crate) upper_bound: RangeBound,
+ pub(crate) r#if: Option<Expression>,
+}
+
+impl fmt::Display for Range {
+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
+ write!(formatter, "{} {}", self.lower_bound, self.upper_bound)
+ }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct Prompt {
+ pub(crate) r#if: Option<Expression>,
+}
+
+#[derive(Clone, Debug)]
+pub(crate) enum Attribute {
+ Default(DefaultAttribute),
+ DependsOn(Expression),
+ Select(Select),
+ Imply(Imply),
+ Range(Range),
+ Help(String),
+ Prompt(Prompt),
+}
+
+#[derive(Debug)]
+pub(crate) struct Config {
+ pub(crate) symbol: String,
+ pub(crate) attributes: Vec<Attribute>,
+}
+
+#[derive(Debug)]
+pub(crate) struct KconfigMenu {
+ pub(crate) depends_on: Vec<Expression>,
+ pub(crate) entries: Vec<Entry>,
+}
+
+#[derive(Debug)]
+pub(crate) struct Choice {
+ pub(crate) options: Vec<Attribute>,
+ pub(crate) entries: Vec<Entry>,
+}
+
+#[derive(Debug)]
+pub(crate) struct If {
+ pub(crate) condition: Expression,
+ pub(crate) entries: Vec<Entry>,
+}
+
+#[derive(Debug)]
+pub(crate) enum Entry {
+ Config(Config),
+ Menu(KconfigMenu),
+ Choice(Choice),
+ If(If),
+ Comment,
+}
+
+#[expect(
+ clippy::disallowed_methods,
+ reason = "host tools use the platform c_char ABI, not the kernel's unsigned c_char"
+)]
+fn string(pointer: *const c_char) -> Option<String> {
+ if pointer.is_null() {
+ None
+ } else {
+ // SAFETY: All strings in the parse tree are NUL-terminated.
+ let string = unsafe { CStr::from_ptr(pointer) };
+ Some(string.to_string_lossy().into_owned())
+ }
+}
+
+fn symbol_name(symbol: *const Symbol) -> String {
+ // SAFETY: Callers only pass non-null symbols from the parse tree.
+ string(unsafe { (*symbol).name }).unwrap_or_default()
+}
+
+/// Appends one `expr_print()` fragment to the `String` that backs `data`.
+///
+/// # Safety
+///
+/// `data` must be the `String` pointer supplied by [`render()`], and `text`
+/// must be NUL-terminated or null.
+unsafe extern "C" fn append_expr_text(
+ data: *mut c_void,
+ _symbol: *mut Symbol,
+ text: *const c_char,
+) {
+ // SAFETY: `render()` passes a `String` that outlives the `expr_print()`
+ // call.
+ let output = unsafe { &mut *data.cast::<String>() };
+ if let Some(text) = string(text) {
+ output.push_str(&text);
+ }
+}
+
+/// Renders `expr` the way the frontends print it.
+fn render(expr: *const Expr, prevtoken: expr_type) -> String {
+ let mut output = String::new();
+ // SAFETY: `expr` points into the parse tree, the callback is
+ // synchronous, and `output` outlives the call.
+ unsafe {
+ expr_print(
+ expr,
+ Some(append_expr_text),
+ ptr::from_mut(&mut output).cast(),
+ prevtoken as c_int,
+ );
+ }
+ output
+}
+
+fn expression(expr: *const Expr) -> Option<Expression> {
+ if expr.is_null() {
+ return None;
+ }
+
+ let rendered = render(expr, E_NONE);
+
+ // Render the negation of `expr` through the same printer, so that it
+ // can be compared with another rendered expression.
+ // SAFETY: `expr` is non-null, and `E_NOT` nodes keep their operand in
+ // `left.expr`.
+ let negation = if unsafe { (*expr).type_ } == E_NOT {
+ // SAFETY: `expr` is non-null, and `E_NOT` nodes keep their operand in
+ // `left.expr`.
+ render(unsafe { (*expr).left.expr }, E_NONE)
+ } else {
+ format!("!{}", render(expr, E_NOT))
+ };
+
+ Some(Expression { rendered, negation })
+}
+
+fn dependencies(expr: *const Expr, output: &mut Vec<Expression>) {
+ if expr.is_null() {
+ return;
+ }
+
+ // SAFETY: `expr` points into the parse tree.
+ if unsafe { (*expr).type_ } == E_AND {
+ // SAFETY: `E_AND` nodes have an expression on both sides.
+ let (left, right) = unsafe { ((*expr).left.expr, (*expr).right.expr) };
+ dependencies(left, output);
+ dependencies(right, output);
+ } else if let Some(expr) = expression(expr) {
+ output.push(expr);
+ }
+}
+
+fn range_bound(symbol: *const Symbol) -> RangeBound {
+ RangeBound {
+ // SAFETY: Range expressions contain a symbol in each operand.
+ value: symbol_name(symbol),
+ }
+}
+
+fn attributes(menu: *const Menu, symbol: *const Symbol) -> Vec<Attribute> {
+ let mut attributes = Vec::new();
+
+ let mut menu_dependencies = Vec::new();
+ // SAFETY: `menu` points into the parse tree.
+ dependencies(unsafe { (*menu).dep }, &mut menu_dependencies);
+ attributes.extend(menu_dependencies.into_iter().map(Attribute::DependsOn));
+
+ // SAFETY: `symbol` points into the parse tree.
+ let mut property = unsafe { (*symbol).prop };
+ while !property.is_null() {
+ // A property belongs to the menu node of the location that defined it.
+ // SAFETY: Property links are valid during the callback.
+ if ptr::eq(unsafe { (*property).menu }, menu) {
+ // SAFETY: Property expression pointers remain during the
+ // callback.
+ let (value, condition) = unsafe { ((*property).expr, (*property).visible.expr) };
+ let condition = expression(condition);
+ // SAFETY: `property` points into the parse tree.
+ let property_type = unsafe { (*property).type_ };
+ match property_type {
+ // The parser rewrites a `menuconfig` prompt to `P_MENU`. Menus
+ // and the mainmenu have no symbol for properties, so their
+ // `P_MENU` never ends up in a property list.
+ P_PROMPT | P_MENU => {
+ attributes.push(Attribute::Prompt(Prompt { r#if: condition }));
+ }
+ P_DEFAULT => {
+ if let Some(expression) = expression(value) {
+ attributes.push(Attribute::Default(DefaultAttribute {
+ expression,
+ r#if: condition,
+ }));
+ }
+ }
+ P_SELECT | P_IMPLY => {
+ // SAFETY: Select and imply values are `E_SYMBOL`
+ // expressions, which keep their symbol in `left.sym`.
+ let target = unsafe { (*value).left.sym };
+ let select = Select {
+ // SAFETY: `target` is a non-null symbol.
+ symbol: symbol_name(target),
+ r#if: condition,
+ };
+ if property_type == P_SELECT {
+ attributes.push(Attribute::Select(select));
+ } else {
+ attributes.push(Attribute::Imply(select));
+ }
+ }
+ P_RANGE => {
+ // SAFETY: Range values are `E_RANGE` expressions, which
+ // keep a symbol in each operand.
+ let (lower_bound, upper_bound) =
+ unsafe { ((*value).left.sym, (*value).right.sym) };
+ attributes.push(Attribute::Range(Range {
+ lower_bound: range_bound(lower_bound),
+ upper_bound: range_bound(upper_bound),
+ r#if: condition,
+ }));
+ }
+ _ => {}
+ }
+ }
+ // SAFETY: `property` points into the property list.
+ property = unsafe { (*property).next };
+ }
+
+ // SAFETY: The help pointer is parser-owned and remains valid during the
+ // callback.
+ let help = unsafe { (*menu).help };
+ if let Some(help) = string(help) {
+ attributes.push(Attribute::Help(help));
+ }
+
+ attributes
+}
+
+fn menu_dependencies(menu: *const Menu) -> Vec<Expression> {
+ let mut output = Vec::new();
+ // SAFETY: `menu` points into the parse tree.
+ dependencies(unsafe { (*menu).dep }, &mut output);
+ output
+}
+
+fn entries(parent: *const Menu) -> Vec<Entry> {
+ let mut output = Vec::new();
+ // SAFETY: `parent` points into the parse tree.
+ let mut menu: *const Menu = unsafe { (*parent).list };
+
+ while !menu.is_null() {
+ // SAFETY: Menu links and fields remain valid during the callback.
+ let symbol: *const Symbol = unsafe { (*menu).sym };
+ // SAFETY: `menu` points into the parse tree.
+ let menu_type = unsafe { (*menu).type_ };
+ let entry = match menu_type {
+ // The parser records `config` and `menuconfig` entries as a menu
+ // node carrying a symbol. Neither opens a submenu while parsing;
+ // `menu_finalize()` is what nests later entries underneath them,
+ // and it has not run yet.
+ M_NORMAL | M_MENU if !symbol.is_null() => Entry::Config(Config {
+ symbol: symbol_name(symbol),
+ attributes: attributes(menu, symbol),
+ }),
+ M_NORMAL => panic!("Kconfig 'config' entry without a symbol"),
+ M_MENU => Entry::Menu(KconfigMenu {
+ depends_on: menu_dependencies(menu),
+ entries: entries(menu),
+ }),
+ // A choice is recorded as a symbol with no name, so its `options`
+ // are read the same way a config's attributes are.
+ M_CHOICE => Entry::Choice(Choice {
+ options: attributes(menu, symbol),
+ entries: entries(menu),
+ }),
+ M_IF => Entry::If(If {
+ // SAFETY: `If` entries always have a dependency expression.
+ condition: expression(unsafe { (*menu).dep })
+ .unwrap_or_else(|| Expression::constant("y", "n")),
+ entries: entries(menu),
+ }),
+ M_COMMENT => Entry::Comment,
+ value => panic!("unknown Kconfig menu type {value}"),
+ };
+ output.push(entry);
+
+ // SAFETY: `menu` points into the sibling list.
+ menu = unsafe { (*menu).next };
+ }
+
+ output
+}
+
+struct ParseState {
+ entries: Option<Vec<Entry>>,
+ panicked: bool,
+}
+
+/// Collects the parse tree before the parser finalizes (simplifies) it.
+///
+/// # Safety
+///
+/// `data` must point to the [`ParseState`] supplied by [`parse_kconfig()`].
+unsafe extern "C" fn collect_entries(root: *const Menu, data: *mut c_void) {
+ // SAFETY: `parse_kconfig()` passes a non-null `ParseState` that outlives
+ // the synchronous `conf_parse()` call.
+ let state = unsafe { &mut *data.cast::<ParseState>() };
+ match catch_unwind(AssertUnwindSafe(|| entries(root))) {
+ Ok(entries) => state.entries = Some(entries),
+ Err(_) => state.panicked = true,
+ }
+}
+
+/// Parses the Kconfig tree rooted at `name` relative to the current directory.
+///
+/// # Panics
+///
+/// Panics if the parser does not invoke the traversal callback or if
+/// traversing its parse tree encounters an unsupported node.
+#[expect(
+ clippy::disallowed_methods,
+ reason = "host tools use the platform c_char ABI, not the kernel's unsigned c_char"
+)]
+pub(crate) fn parse_kconfig(name: &CStr) -> Vec<Entry> {
+ let mut state = ParseState {
+ entries: None,
+ panicked: false,
+ };
+
+ // SAFETY: `conf_parse()` is synchronous, so `state` outlives the callback
+ // and its user-data pointer.
+ unsafe {
+ conf_set_pre_finalize_callback(Some(collect_entries), ptr::from_mut(&mut state).cast());
+ conf_parse(name.as_ptr());
+ conf_set_pre_finalize_callback(None, ptr::null_mut());
+ }
+ assert!(!state.panicked, "failed to traverse Kconfig graph");
+ state
+ .entries
+ .expect("Kconfig parser calls the traversal callback")
+}
diff --git a/scripts/kconfig/kconfirm/.gitignore b/scripts/kconfig/kconfirm/.gitignore
new file mode 100644
index 000000000000..f21a4d460aee
--- /dev/null
+++ b/scripts/kconfig/kconfirm/.gitignore
@@ -0,0 +1,2 @@
+# SPDX-License-Identifier: GPL-2.0-only
+/kconfirm
diff --git a/scripts/kconfig/kconfirm/analyze.rs b/scripts/kconfig/kconfirm/analyze.rs
new file mode 100644
index 000000000000..192669405a88
--- /dev/null
+++ b/scripts/kconfig/kconfirm/analyze.rs
@@ -0,0 +1,340 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+use crate::{
+ dead_links::{
+ self,
+ check_link,
+ LinkStatus, //
+ },
+ kconfig::{
+ Attribute::*,
+ Choice,
+ Config,
+ Entry,
+ Expression,
+ If,
+ Imply,
+ KconfigMenu,
+ Select, //
+ },
+ output::{
+ Finding,
+ Severity, //
+ },
+ symbol_table::{
+ AttributeDef,
+ SymbolUpdate, //
+ },
+ AnalysisArgs,
+ Check,
+ SymbolTable, //
+};
+use std::{
+ collections::HashSet, //
+ option::Option, //
+};
+
+fn check_text(
+ unique_links: &mut HashSet<String>,
+ text: &str,
+ args: &AnalysisArgs,
+ findings: &mut Vec<Finding>,
+ symbol: Option<&str>,
+ arch: &str,
+ context: &str,
+) {
+ if !args.is_enabled(Check::DeadLink) {
+ return;
+ }
+
+ for link in dead_links::find_links(text) {
+ // Avoid checking the same link more than once.
+ if !unique_links.insert(link.clone()) {
+ continue;
+ }
+
+ let status = check_link(&link);
+ if status != LinkStatus::Ok && status != LinkStatus::ProbablyBlocked {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadLink,
+ symbol: symbol.map(|s| s.to_string()),
+ message: format!(
+ "{} contains link {} with status {}",
+ context,
+ link,
+ status.as_str()
+ ),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+}
+
+#[derive(Clone)]
+pub(crate) struct Context {
+ pub(crate) arch: String,
+ pub(crate) definition_condition: Vec<Expression>,
+ pub(crate) visibility: Vec<Option<Expression>>,
+ pub(crate) dependencies: Vec<Expression>,
+}
+
+impl Context {
+ fn with_arch(arch: &str) -> Context {
+ Context {
+ arch: arch.to_owned(),
+ definition_condition: vec![],
+ visibility: vec![],
+ dependencies: vec![],
+ }
+ }
+
+ fn child(&self) -> Self {
+ self.clone()
+ }
+
+ fn with_dep(mut self, dep: Expression) -> Self {
+ self.dependencies.push(dep);
+ self
+ }
+
+ fn with_visibility(mut self, cond: Option<Expression>) -> Self {
+ self.visibility.push(cond);
+ self
+ }
+
+ fn with_definition(mut self, cond: Expression) -> Self {
+ self.definition_condition.push(cond);
+ self
+ }
+}
+
+fn recurse_entries(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ unique_links: &mut HashSet<String>,
+ entries: Vec<Entry>,
+ ctx: Context,
+ findings: &mut Vec<Finding>,
+) {
+ for entry in entries {
+ process_entry(args, symtab, unique_links, entry, ctx.clone(), findings);
+ }
+}
+
+/// Traverses parsed Kconfig entries, populates `symtab`, and returns findings.
+pub(crate) fn analyze(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ arch: &str,
+ entries: Vec<Entry>,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+ let mut unique_links = HashSet::new();
+
+ let ctx = Context::with_arch(arch);
+
+ recurse_entries(args, symtab, &mut unique_links, entries, ctx, &mut findings);
+
+ findings
+}
+
+// Config here refers to a config option in the parse tree.
+fn handle_config(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ entry: Config,
+ ctx: &Context,
+ findings: &mut Vec<Finding>,
+ unique_links: &mut HashSet<String>,
+) {
+ let config_symbol = entry.symbol;
+
+ let mut child_ctx = ctx.child();
+
+ let mut dependencies = Vec::new();
+ let mut kconfig_selects: Vec<Select> = Vec::new();
+ let mut kconfig_implies: Vec<Imply> = Vec::new();
+ let mut ranges = Vec::new();
+ let mut defaults = Vec::new();
+ let mut found_prompt = false;
+
+ for attribute in entry.attributes {
+ match attribute {
+ Default(default) => defaults.push(default),
+ DependsOn(depends_on) => dependencies.push(depends_on),
+ Select(select) => kconfig_selects.push(select),
+ Imply(imply) => kconfig_implies.push(imply),
+ // Kconfig range bounds are inclusive.
+ Range(range) => ranges.push(range),
+ Help(h) => {
+ check_text(
+ unique_links,
+ &h,
+ args,
+ findings,
+ Some(&config_symbol),
+ &ctx.arch,
+ "help text",
+ );
+ }
+
+ // A prompt's `if` expression determines its visibility.
+ Prompt(prompt) => {
+ found_prompt = true;
+ if let Some(c) = prompt.r#if {
+ child_ctx = child_ctx.with_visibility(Some(c));
+ }
+ }
+ }
+ }
+
+ if !found_prompt {
+ child_ctx = child_ctx.with_visibility(None);
+ }
+
+ // Add inherited dependencies to this symbol's own dependencies.
+ dependencies.extend(child_ctx.dependencies.clone());
+ symtab.merge_insert(SymbolUpdate {
+ symbol: config_symbol.clone(),
+ is_definition: true,
+ attributes: AttributeDef {
+ dependencies,
+ ranges,
+ defaults,
+ visibility: child_ctx.visibility.clone(),
+ selects: kconfig_selects
+ .clone()
+ .into_iter()
+ .map(|select| (select.symbol, select.r#if))
+ .collect(),
+ implies: kconfig_implies
+ .into_iter()
+ .map(|imply| (imply.symbol, imply.r#if))
+ .collect(),
+ },
+ definition_condition: child_ctx.definition_condition.clone(),
+ selected_by: None,
+ });
+
+ for select in kconfig_selects {
+ symtab.merge_insert(SymbolUpdate {
+ symbol: select.symbol,
+ is_definition: false,
+ attributes: AttributeDef::default(),
+ definition_condition: child_ctx.definition_condition.clone(),
+ selected_by: Some((config_symbol.clone(), select.r#if)),
+ });
+ }
+}
+
+fn handle_menu(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ entry: KconfigMenu,
+ ctx: &Context,
+ findings: &mut Vec<Finding>,
+ unique_links: &mut HashSet<String>,
+) {
+ let mut child_ctx = ctx.child();
+
+ for dep in entry.depends_on {
+ child_ctx = child_ctx.with_dep(dep.clone());
+ // Menu dependencies also constrain the visibility of their contained
+ // entries.
+ child_ctx = child_ctx.with_visibility(Some(dep));
+ }
+
+ let nested_entries = entry.entries;
+
+ recurse_entries(
+ args,
+ symtab,
+ unique_links,
+ nested_entries,
+ child_ctx.clone(),
+ findings,
+ );
+}
+
+fn handle_choice(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ entry: Choice,
+ ctx: &Context,
+ findings: &mut Vec<Finding>,
+ unique_links: &mut HashSet<String>,
+) {
+ let mut child_ctx = ctx.child();
+
+ // Choice members inherit both outer and choice-specific dependencies.
+ for attribute in entry.options {
+ match attribute {
+ DependsOn(depends_on) => {
+ child_ctx = child_ctx.with_dep(depends_on);
+ }
+
+ // A prompt's `if` expression determines its visibility.
+ Prompt(prompt) => {
+ if let Some(i) = prompt.r#if {
+ child_ctx = child_ctx.with_visibility(Some(i));
+ }
+ }
+ _ => {}
+ }
+ }
+
+ let nested_entries = entry.entries;
+
+ recurse_entries(
+ args,
+ symtab,
+ unique_links,
+ nested_entries,
+ child_ctx.clone(),
+ findings,
+ );
+}
+
+fn handle_if(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ entry: If,
+ ctx: &Context,
+ findings: &mut Vec<Finding>,
+ unique_links: &mut HashSet<String>,
+) {
+ let mut child_ctx = ctx.child();
+ child_ctx = child_ctx.with_definition(entry.condition.clone());
+ child_ctx = child_ctx.with_dep(entry.condition);
+ let nested_entries = entry.entries;
+
+ recurse_entries(
+ args,
+ symtab,
+ unique_links,
+ nested_entries,
+ child_ctx,
+ findings,
+ );
+}
+
+fn process_entry(
+ args: &AnalysisArgs,
+ symtab: &mut SymbolTable,
+ unique_links: &mut HashSet<String>,
+ entry: Entry,
+ ctx: Context,
+ findings: &mut Vec<Finding>,
+) {
+ // Each handler updates the context for the construct it processes.
+ match entry {
+ Entry::Config(c) => handle_config(args, symtab, c, &ctx, findings, unique_links),
+ Entry::Menu(m) => handle_menu(args, symtab, m, &ctx, findings, unique_links),
+ Entry::Choice(c) => handle_choice(args, symtab, c, &ctx, findings, unique_links),
+ Entry::If(i) => handle_if(args, symtab, i, &ctx, findings, unique_links),
+ Entry::Comment => {}
+ }
+}
diff --git a/scripts/kconfig/kconfirm/arch.rs b/scripts/kconfig/kconfirm/arch.rs
new file mode 100644
index 000000000000..9af24771a341
--- /dev/null
+++ b/scripts/kconfig/kconfirm/arch.rs
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+use std::{
+ fs,
+ io,
+ path::Path, //
+};
+
+/// Returns the architecture directory names in a Linux source tree.
+pub(crate) fn available_architectures(linux_path: &Path) -> io::Result<Vec<String>> {
+ let mut architectures = Vec::new();
+
+ for entry in fs::read_dir(linux_path.join("arch"))? {
+ let entry = entry?;
+ if !entry.file_type()?.is_dir() {
+ continue;
+ }
+
+ let architecture = entry.file_name().into_string().map_err(|name| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!("architecture directory name is not valid UTF-8: {name:?}"),
+ )
+ })?;
+ architectures.push(architecture);
+ }
+
+ architectures.sort();
+ Ok(architectures)
+}
+
+/// Maps the build architecture to its source directory, like the top-level
+/// Makefile's `SRCARCH`.
+pub(crate) fn arch_to_srcarch(arch: &str) -> &str {
+ match arch {
+ "i386" | "x86_64" => "x86",
+ "sparc32" | "sparc64" => "sparc",
+ "parisc64" => "parisc",
+ _ => arch,
+ }
+}
+
+/// Maps an architecture source directory to its Kconfig option name.
+pub(crate) fn arch_dir_to_config(arch_dir: &str) -> String {
+ match arch_dir {
+ "powerpc" => String::from("PPC"),
+ "sh" => String::from("SUPERH"),
+ "um" => String::from("UML"),
+ _ => arch_dir.to_uppercase(),
+ }
+}
diff --git a/scripts/kconfig/kconfirm/checks.rs b/scripts/kconfig/kconfirm/checks.rs
new file mode 100644
index 000000000000..c810db342fb7
--- /dev/null
+++ b/scripts/kconfig/kconfirm/checks.rs
@@ -0,0 +1,748 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+use crate::{
+ kconfig::{
+ Expression,
+ RangeBound, //
+ },
+ output::{
+ Finding,
+ Severity, //
+ },
+ symbol_table::{
+ AttributeDef,
+ SymbolInfo, //
+ },
+};
+use std::{
+ collections::HashSet,
+ num::ParseIntError,
+ str::FromStr, //
+};
+
+/// A diagnostic check that can be enabled or disabled from the command line.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub(crate) enum Check {
+ DeadLink,
+ SelectVisible,
+ DuplicateDependency,
+ DuplicateRange,
+ DeadRange,
+ DuplicateSelect,
+ DeadSelect,
+ DeadDefault,
+ ConstantCondition,
+ DuplicateDefault,
+ DuplicateDefaultValue,
+ DuplicateImply,
+ DeadImply,
+ ReverseRange,
+}
+
+impl Check {
+ /// Returns the stable command-line and diagnostic identifier for a check.
+ pub(crate) fn as_str(self) -> &'static str {
+ match self {
+ Check::DeadLink => "dead_link",
+ Check::SelectVisible => "select_visible",
+ Check::DuplicateDependency => "duplicate_dependency",
+ Check::DuplicateRange => "duplicate_range",
+ Check::DeadRange => "dead_range",
+ Check::DuplicateSelect => "duplicate_select",
+ Check::DeadSelect => "dead_select",
+ Check::DeadDefault => "dead_default",
+ Check::ConstantCondition => "constant_condition",
+ Check::DuplicateDefault => "duplicate_default",
+ Check::DuplicateDefaultValue => "duplicate_default_value",
+ Check::DuplicateImply => "duplicate_imply",
+ Check::DeadImply => "dead_imply",
+ Check::ReverseRange => "reverse_range",
+ }
+ }
+}
+
+#[derive(Debug)]
+pub(crate) struct ParseCheckError {
+ pub(crate) input: String,
+}
+
+impl std::fmt::Display for ParseCheckError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ write!(f, "unknown check '{}'", self.input)
+ }
+}
+
+impl std::error::Error for ParseCheckError {}
+
+impl FromStr for Check {
+ type Err = ParseCheckError;
+
+ fn from_str(name: &str) -> Result<Self, Self::Err> {
+ match name {
+ "dead_link" => Ok(Check::DeadLink),
+ "select_visible" => Ok(Check::SelectVisible),
+ "duplicate_dependency" => Ok(Check::DuplicateDependency),
+ "duplicate_range" => Ok(Check::DuplicateRange),
+ "dead_range" => Ok(Check::DeadRange),
+ "duplicate_select" => Ok(Check::DuplicateSelect),
+ "dead_select" => Ok(Check::DeadSelect),
+ "dead_default" => Ok(Check::DeadDefault),
+ "constant_condition" => Ok(Check::ConstantCondition),
+ "duplicate_default" => Ok(Check::DuplicateDefault),
+ "duplicate_default_value" => Ok(Check::DuplicateDefaultValue),
+ "duplicate_imply" => Ok(Check::DuplicateImply),
+ "dead_imply" => Ok(Check::DeadImply),
+ "reverse_range" => Ok(Check::ReverseRange),
+ _ => Err(ParseCheckError {
+ input: name.to_string(),
+ }),
+ }
+ }
+}
+
+/// The set of checks enabled for one analysis run.
+#[derive(Debug)]
+pub(crate) struct AnalysisArgs {
+ enabled_checks: HashSet<Check>,
+}
+
+impl AnalysisArgs {
+ /// Creates an analysis configuration with no checks enabled.
+ pub(crate) fn new() -> Self {
+ Self {
+ enabled_checks: HashSet::new(),
+ }
+ }
+
+ /// Enables `check`, returning whether it was newly enabled.
+ pub(crate) fn enable_check(&mut self, check: Check) -> bool {
+ self.enabled_checks.insert(check)
+ }
+
+ /// Disables `check`, returning whether it had been enabled.
+ pub(crate) fn disable_check(&mut self, check: Check) -> bool {
+ self.enabled_checks.remove(&check)
+ }
+
+ /// Returns whether `check` is enabled.
+ pub(crate) fn is_enabled(&self, check: Check) -> bool {
+ self.enabled_checks.contains(&check)
+ }
+}
+
+/// Checks int/hex ranges with lower bound > upper bound.
+pub(crate) fn check_reverse_ranges(
+ arch: &str,
+ var_symbol: &str,
+ info: &AttributeDef,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+
+ for range in &info.ranges {
+ // Return an error when a bound cannot be parsed as an `i128`.
+ fn range_bound_to_int(range_bound: &RangeBound) -> Result<i128, ParseIntError> {
+ if range_bound.value.starts_with("0x") || range_bound.value.starts_with("0X") {
+ let trimmed = range_bound
+ .value
+ .trim_start_matches("0x")
+ .trim_start_matches("0X");
+ i128::from_str_radix(trimmed, 16)
+ } else {
+ range_bound.value.parse()
+ }
+ }
+
+ let maybe_lower_bound = range_bound_to_int(&range.lower_bound);
+ let maybe_upper_bound = range_bound_to_int(&range.upper_bound);
+
+ match (maybe_lower_bound, maybe_upper_bound) {
+ (Ok(lower_bound), Ok(upper_bound)) => {
+ if lower_bound > upper_bound {
+ let message = format!(
+ "reverse range {} for config option: {}, no value is valid",
+ range, var_symbol,
+ );
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::ReverseRange,
+ symbol: Some(var_symbol.to_owned()),
+ arch: arch.to_owned(),
+ message,
+ });
+ }
+ }
+ (Result::Err(_), _) | (_, Result::Err(_)) => continue,
+ }
+ }
+
+ findings
+}
+
+pub(crate) fn check_constant_conditions(
+ arch: &str,
+ var_symbol: &str,
+ info: &AttributeDef,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+ let default_conditions: Vec<&Expression> = info
+ .defaults
+ .iter()
+ .filter_map(|conditional_default| conditional_default.r#if.as_ref())
+ .collect();
+
+ check_conditions(
+ arch,
+ &mut findings,
+ var_symbol,
+ &info.dependencies,
+ default_conditions,
+ "default",
+ );
+
+ let select_conditions: Vec<&Expression> = info
+ .selects
+ .iter()
+ .filter_map(|conditional_select| conditional_select.1.as_ref())
+ .collect();
+
+ check_conditions(
+ arch,
+ &mut findings,
+ var_symbol,
+ &info.dependencies,
+ select_conditions,
+ "select",
+ );
+
+ let imply_conditions: Vec<&Expression> = info
+ .implies
+ .iter()
+ .filter_map(|imp| imp.1.as_ref())
+ .collect();
+
+ check_conditions(
+ arch,
+ &mut findings,
+ var_symbol,
+ &info.dependencies,
+ imply_conditions,
+ "imply",
+ );
+
+ let range_conditions: Vec<&Expression> = info
+ .ranges
+ .iter()
+ .filter_map(|conditional_range| conditional_range.r#if.as_ref())
+ .collect();
+
+ check_conditions(
+ arch,
+ &mut findings,
+ var_symbol,
+ &info.dependencies,
+ range_conditions,
+ "range",
+ );
+
+ fn check_conditions(
+ arch: &str,
+ findings: &mut Vec<Finding>,
+ symbol: &str,
+ dependencies: &[Expression],
+ attribute_conditions: Vec<&Expression>,
+ context: &str,
+ ) {
+ for attribute_condition in attribute_conditions {
+ if dependencies.contains(attribute_condition) {
+ let message = format!(
+ "constant {} condition 'if {}' for config option: {}, this condition is a dependency and will always be true",
+ context,
+ attribute_condition,
+ symbol,
+ );
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::ConstantCondition,
+ symbol: Some(symbol.to_owned()),
+ arch: arch.to_owned(),
+ message,
+ });
+ } else if dependencies
+ .iter()
+ .any(|dependency| attribute_condition.is_negation_of(dependency))
+ {
+ let message = format!(
+ "constant {} condition 'if {}' for config option: {}, this condition negates a dependency and will always be false",
+ context, attribute_condition, symbol,
+ );
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::ConstantCondition,
+ symbol: Some(symbol.to_owned()),
+ arch: arch.to_owned(),
+ message,
+ });
+ }
+ }
+ }
+ findings
+}
+
+pub(crate) fn check_variable_info(
+ args: &AnalysisArgs,
+ var_symbol: &str,
+ arch: &str,
+ info: &AttributeDef,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+
+ if args.is_enabled(Check::DuplicateDependency) {
+ findings.extend(check_duplicate_dependencies(arch, var_symbol, info));
+ }
+
+ if args.is_enabled(Check::DuplicateImply) || args.is_enabled(Check::DeadImply) {
+ findings.extend(check_implies(arch, var_symbol, info, args));
+ }
+
+ if args.is_enabled(Check::DuplicateRange) || args.is_enabled(Check::DeadRange) {
+ findings.extend(check_ranges(arch, var_symbol, info, args));
+ }
+
+ if args.is_enabled(Check::DuplicateSelect) || args.is_enabled(Check::DeadSelect) {
+ findings.extend(check_selects(arch, var_symbol, info, args));
+ }
+
+ if args.is_enabled(Check::ConstantCondition) {
+ findings.extend(check_constant_conditions(arch, var_symbol, info));
+ }
+
+ if args.is_enabled(Check::DeadDefault)
+ || args.is_enabled(Check::DuplicateDefault)
+ || args.is_enabled(Check::DuplicateDefaultValue)
+ {
+ findings.extend(check_defaults(arch, var_symbol, info, args));
+ }
+
+ if args.is_enabled(Check::ReverseRange) {
+ findings.extend(check_reverse_ranges(arch, var_symbol, info));
+ }
+
+ findings
+}
+
+pub(crate) fn check_select_visible(
+ var_symbol: &str,
+ info: &SymbolInfo,
+ arch: &str,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+
+ if info.selected_by.is_empty() {
+ return Vec::new();
+ }
+
+ for (selector, select_conditions) in &info.selected_by {
+ for _condition in select_conditions {
+ let message = format!(
+ "selects the visible {}; consider using 'depends on' or 'imply' instead",
+ var_symbol
+ );
+
+ for (if_conditions, attributes) in &info.attribute_defs {
+ if if_conditions.is_empty() && attributes.visibility.is_empty() {
+ // Empty visibility makes the symbol unconditionally
+ // visible (for this architecture).
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::SelectVisible,
+ symbol: Some(selector.to_owned()),
+ message: message.clone(),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+ }
+ }
+
+ findings
+}
+
+fn is_duplicate<T: Eq + std::hash::Hash>(set: &mut HashSet<T>, key: T) -> bool {
+ !set.insert(key)
+}
+
+fn check_duplicate_dependencies(arch: &str, var_symbol: &str, info: &AttributeDef) -> Vec<Finding> {
+ let mut findings = Vec::new();
+ let mut seen = HashSet::new();
+
+ for dep in &info.dependencies {
+ if is_duplicate(&mut seen, dep.to_string()) {
+ let message = format!("duplicate dependency on {dep}");
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateDependency,
+ symbol: Some(var_symbol.to_owned()),
+ message,
+ arch: arch.to_owned(),
+ });
+ }
+ }
+
+ findings
+}
+
+/// Checks for `duplicate_imply` and `dead_imply`.
+fn check_implies(
+ arch: &str,
+ var_symbol: &str,
+ info: &AttributeDef,
+ args: &AnalysisArgs,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+
+ // Symbols implied unconditionally.
+ let mut unconditional: HashSet<String> = HashSet::new();
+
+ // Each tuple represents (symbol, implication condition).
+ let mut conditional: HashSet<(String, String)> = HashSet::new();
+
+ for (imply_var, imply_cond) in &info.implies {
+ match &imply_cond {
+ Some(cond) => {
+ let cond_str = cond.to_string();
+
+ // Report duplicate conditional implies. The insertion must
+ // happen even when the check is disabled.
+ if !conditional.insert((imply_var.clone(), cond_str.clone()))
+ && args.is_enabled(Check::DuplicateImply)
+ {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateImply,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!(
+ "duplicate imply of {} with condition {}",
+ imply_var, cond_str
+ ),
+ arch: arch.to_owned(),
+ });
+ }
+
+ // An unconditional imply makes this conditional one dead.
+ if unconditional.contains(imply_var) && args.is_enabled(Check::DeadImply) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadImply,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead imply of {}", imply_var),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+
+ None => {
+ // Report duplicate unconditional implies. The insertion must
+ // happen even when the check is disabled.
+ if !unconditional.insert(imply_var.clone())
+ && args.is_enabled(Check::DuplicateImply)
+ {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateImply,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("duplicate imply of {}", imply_var),
+ arch: arch.to_owned(),
+ });
+ }
+
+ // An unconditional imply makes this conditional one dead.
+ if args.is_enabled(Check::DeadImply) {
+ for (sym, _) in &conditional {
+ if sym == imply_var {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadImply,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead imply of {}", imply_var),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ findings
+}
+
+/// Checks `duplicate_range` and `dead_range`.
+fn check_ranges(
+ arch: &str,
+ var_symbol: &str,
+ info: &AttributeDef,
+ args: &AnalysisArgs,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+
+ // Unconditional ranges, for duplicate detection.
+ let mut unconditional: HashSet<String> = HashSet::new();
+
+ // Each tuple represents (range bounds, condition).
+ let mut conditional: HashSet<(String, String)> = HashSet::new();
+
+ // Kconfig uses the first range whose condition is satisfied. Once an
+ // unconditional range has been reached, every non-duplicate range after it
+ // is unreachable, regardless of its bounds.
+ let mut seen_unconditional = false;
+
+ for range in &info.ranges {
+ // Use both bounds to identify a range.
+ let range_key = format!("{} {}", range.lower_bound, range.upper_bound);
+
+ let duplicate = match &range.r#if {
+ Some(cond) => {
+ let cond_str = cond.to_string();
+
+ // Report duplicate conditional ranges. The insertion must
+ // happen even when the check is disabled.
+ let duplicate = !conditional.insert((range_key, cond_str.clone()));
+ if duplicate && args.is_enabled(Check::DuplicateRange) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateRange,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("duplicate range {} with condition {}", range, cond_str),
+ arch: arch.to_owned(),
+ });
+ }
+ duplicate
+ }
+
+ None => {
+ // Report duplicate unconditional ranges. The insertion must
+ // happen even when the check is disabled.
+ let duplicate = !unconditional.insert(range_key);
+ if duplicate && args.is_enabled(Check::DuplicateRange) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateRange,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("duplicate range {}", range),
+ arch: arch.to_owned(),
+ });
+ }
+ duplicate
+ }
+ };
+
+ if seen_unconditional && !duplicate && args.is_enabled(Check::DeadRange) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadRange,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead range of {}", range),
+ arch: arch.to_owned(),
+ });
+ }
+
+ if range.r#if.is_none() {
+ seen_unconditional = true;
+ }
+ }
+
+ findings
+}
+
+/// Checks for `duplicate_select` and `dead_select`.
+fn check_selects(
+ arch: &str,
+ var_symbol: &str,
+ info: &AttributeDef,
+ args: &AnalysisArgs,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+
+ // Unconditional selects.
+ let mut unconditional: HashSet<String> = HashSet::new();
+
+ // Each tuple represents (symbol, condition).
+ let mut conditional: HashSet<(String, String)> = HashSet::new();
+
+ for select in &info.selects {
+ let select_var = select.0.clone();
+
+ match &select.1 {
+ Some(cond) => {
+ let cond_str = cond.to_string();
+
+ // Report duplicate conditional selects. The insertion must
+ // happen even when the check is disabled.
+ if !conditional.insert((select_var.clone(), cond_str.clone()))
+ && args.is_enabled(Check::DuplicateSelect)
+ {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateSelect,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!(
+ "duplicate select of {} with condition {}",
+ select.0, cond_str
+ ),
+ arch: arch.to_owned(),
+ });
+ }
+
+ // An unconditional select makes this conditional one dead.
+ if unconditional.contains(&select_var) && args.is_enabled(Check::DeadSelect) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadSelect,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead select of {}", select.0),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+
+ None => {
+ // Report duplicate unconditional selects. The insertion must
+ // happen even when the check is disabled.
+ if !unconditional.insert(select_var.clone())
+ && args.is_enabled(Check::DuplicateSelect)
+ {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateSelect,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("duplicate select of {}", select.0),
+ arch: arch.to_owned(),
+ });
+ }
+
+ // An unconditional select makes this conditional one dead.
+ if args.is_enabled(Check::DeadSelect) {
+ for (sym, _) in &conditional {
+ if sym == &select_var {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadSelect,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead select of {}", select.0),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+ }
+ }
+ }
+ }
+
+ findings
+}
+
+fn check_defaults(
+ arch: &str,
+ var_symbol: &str,
+ info: &AttributeDef,
+ args: &AnalysisArgs,
+) -> Vec<Finding> {
+ let mut findings = Vec::new();
+ let mut seen_conditions = HashSet::new();
+ let mut seen_values = HashSet::new();
+ let mut seen_unconditional_values: HashSet<String> = HashSet::new();
+ let mut already_unconditional = false;
+
+ for default in &info.defaults {
+ let val_str = default.expression.to_string();
+
+ let has_real_condition = match &default.r#if {
+ Some(cond) => {
+ let cond_str = cond.to_string();
+ !cond_str.is_empty()
+ }
+ None => false,
+ };
+
+ let is_value_dup = if has_real_condition {
+ is_duplicate(&mut seen_values, val_str.clone())
+ } else {
+ false
+ };
+
+ // Any default following an unconditional one is dead or a duplicate.
+ if already_unconditional {
+ let duplicate_unconditional =
+ default.r#if.is_none() && seen_unconditional_values.contains(&val_str);
+
+ if duplicate_unconditional && args.is_enabled(Check::DuplicateDefault) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateDefault,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("duplicate default of {}", val_str),
+ arch: arch.to_owned(),
+ });
+ } else if !duplicate_unconditional && args.is_enabled(Check::DeadDefault) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadDefault,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead default of {}", val_str),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+
+ if args.is_enabled(Check::DuplicateDefaultValue) {
+ if default.r#if.is_some() && is_value_dup {
+ findings.push(Finding {
+ severity: Severity::Style,
+ check: Check::DuplicateDefaultValue,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!(
+ "duplicate default value of {}; consider combining the conditions with a logical-or: ||",
+ val_str
+ ),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+
+ match &default.r#if {
+ Some(cond) => {
+ if is_duplicate(&mut seen_conditions, cond.to_string()) {
+ if is_value_dup {
+ if args.is_enabled(Check::DuplicateDefault) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DuplicateDefault,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("duplicate default of {}", val_str),
+ arch: arch.to_owned(),
+ });
+ }
+ } else {
+ if args.is_enabled(Check::DeadDefault) {
+ findings.push(Finding {
+ severity: Severity::Warning,
+ check: Check::DeadDefault,
+ symbol: Some(var_symbol.to_owned()),
+ message: format!("dead default of {}", val_str),
+ arch: arch.to_owned(),
+ });
+ }
+ }
+ }
+ }
+ None => {
+ seen_unconditional_values.insert(val_str);
+ already_unconditional = true;
+ }
+ }
+ }
+
+ findings
+}
diff --git a/scripts/kconfig/kconfirm/dead_links.rs b/scripts/kconfig/kconfirm/dead_links.rs
new file mode 100644
index 000000000000..205ba820fa44
--- /dev/null
+++ b/scripts/kconfig/kconfirm/dead_links.rs
@@ -0,0 +1,230 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+use std::{
+ collections::HashSet,
+ process::{
+ Command,
+ Stdio, //
+ },
+};
+
+struct HttpResponse {
+ response_code: u16,
+ location: Option<String>,
+}
+
+/// Verifies that the `curl` command is available.
+pub(crate) fn check_curl_available() -> Result<(), String> {
+ match Command::new("curl")
+ .arg("--version")
+ .stdout(Stdio::null())
+ .stderr(Stdio::null())
+ .status()
+ {
+ Ok(status) if status.success() => Ok(()),
+ Ok(status) => Err(format!("`curl --version` exited with {status}")),
+ Err(error) => Err(format!("failed to execute `curl`: {error}")),
+ }
+}
+
+/// Performs an HTTP `HEAD` request without following redirects.
+fn head_request(url: &str) -> Result<HttpResponse, String> {
+ let output = Command::new("curl")
+ .args([
+ "--head",
+ "--silent",
+ "--show-error",
+ "--max-time",
+ "10",
+ "--user-agent",
+ "link-checker/1.0",
+ "--output",
+ "/dev/null",
+ "--write-out",
+ "%{http_code}\n%{redirect_url}",
+ "--url",
+ url,
+ ])
+ .output()
+ .map_err(|error| format!("failed to execute `curl`: {error}"))?;
+
+ if !output.status.success() {
+ let error = String::from_utf8_lossy(&output.stderr);
+ let error = error.trim();
+
+ return if error.is_empty() {
+ Err(format!("`curl` exited with {}", output.status))
+ } else {
+ Err(error.to_owned())
+ };
+ }
+
+ let output = String::from_utf8(output.stdout)
+ .map_err(|error| format!("`curl` produced invalid UTF-8: {error}"))?;
+ let (response_code, location) = output
+ .split_once('\n')
+ .ok_or("`curl` did not produce an HTTP response code")?;
+ let response_code = response_code
+ .parse()
+ .map_err(|error| format!("invalid HTTP response code from `curl`: {error}"))?;
+ let location = location.trim();
+
+ Ok(HttpResponse {
+ response_code,
+ location: (!location.is_empty()).then(|| location.to_owned()),
+ })
+}
+
+/// Result of checking whether a help-text link is reachable.
+#[derive(PartialEq, Debug)]
+pub(crate) enum LinkStatus {
+ Ok,
+ ProbablyBlocked,
+ Redirected(String),
+ NotFound,
+ ServerError,
+ Unreachable(String),
+}
+
+impl LinkStatus {
+ /// Returns a stable diagnostic label for the status.
+ pub(crate) fn as_str(&self) -> &'static str {
+ match self {
+ Self::Ok => "ok",
+ Self::ProbablyBlocked => "probably blocked",
+ Self::Redirected(_) => "redirected",
+ Self::NotFound => "not found",
+ Self::ServerError => "server error",
+ Self::Unreachable(_) => "unreachable",
+ }
+ }
+}
+
+/// Checks one link without following redirects.
+pub(crate) fn check_link(url: &str) -> LinkStatus {
+ let Some((scheme, _)) = url.split_once("://") else {
+ return LinkStatus::Unreachable("invalid URL".into());
+ };
+
+ if scheme.eq_ignore_ascii_case("http") || scheme.eq_ignore_ascii_case("https") {
+ check_http(url)
+ } else {
+ LinkStatus::Unreachable("unsupported URL scheme".into())
+ }
+}
+
+fn check_http(url: &str) -> LinkStatus {
+ let response = match head_request(url) {
+ Ok(r) => r,
+ Err(e) => return LinkStatus::Unreachable(e),
+ };
+
+ match response.response_code {
+ 200..=299 => LinkStatus::Ok,
+
+ 301 | 302 | 303 | 307 | 308 => {
+ LinkStatus::Redirected(response.location.unwrap_or_else(|| "unknown".into()))
+ }
+
+ 403 | 429 => LinkStatus::ProbablyBlocked,
+
+ 404 => LinkStatus::NotFound,
+
+ 500..=599 => LinkStatus::ServerError,
+
+ _ => LinkStatus::ProbablyBlocked,
+ }
+}
+
+/// Extracts links from a Kconfig help text.
+pub(crate) fn find_links(text: &str) -> Vec<String> {
+ fn is_scheme_char(c: u8) -> bool {
+ c.is_ascii_alphanumeric() || matches!(c, b'+' | b'-' | b'.')
+ }
+
+ fn is_url_terminator(c: u8) -> bool {
+ c.is_ascii_whitespace() || matches!(c, b'"' | b'\'' | b'<' | b'>')
+ }
+
+ let bytes = text.as_bytes();
+
+ let mut links = Vec::new();
+ let mut seen = HashSet::new();
+
+ let mut i = 0;
+
+ while i + 3 < bytes.len() {
+ if bytes[i] == b':' && bytes[i + 1] == b'/' && bytes[i + 2] == b'/' {
+ // Walk backward to find the start of the scheme.
+ let mut start = i;
+
+ while start > 0 && is_scheme_char(bytes[start - 1]) {
+ start -= 1;
+ }
+
+ // Require a non-empty scheme.
+ if start == i {
+ i += 3;
+ continue;
+ }
+
+ // Require an alphabetic first character.
+ if !bytes[start].is_ascii_alphabetic() {
+ i += 3;
+ continue;
+ }
+
+ // Walk forward to the end of the URL.
+ let mut end = i + 3;
+
+ while end < bytes.len() && !is_url_terminator(bytes[end]) {
+ end += 1;
+ }
+
+ let mut url = &text[start..end];
+
+ // Trim trailing punctuation.
+ url = url.trim_end_matches(&['.', ',', ';', ':', '!', '?'][..]);
+
+ // Trim unmatched Markdown delimiters.
+ while let Some(last) = url.chars().last() {
+ let trim = match last {
+ ')' => url.matches('(').count() < url.matches(')').count(),
+
+ ']' => url.matches('[').count() < url.matches(']').count(),
+
+ '}' => url.matches('{').count() < url.matches('}').count(),
+
+ _ => false,
+ };
+
+ if trim {
+ url = &url[..url.len() - last.len_utf8()];
+ } else {
+ break;
+ }
+ }
+
+ let Some((scheme, _)) = url.split_once("://") else {
+ i = end;
+ continue;
+ };
+ if !scheme.eq_ignore_ascii_case("http") && !scheme.eq_ignore_ascii_case("https") {
+ i = end;
+ continue;
+ }
+
+ if seen.insert(url) {
+ links.push(url.to_string());
+ }
+
+ i = end;
+ } else {
+ i += 1;
+ }
+ }
+
+ links
+}
diff --git a/scripts/kconfig/kconfirm/kconfirm-cfg.sh b/scripts/kconfig/kconfirm/kconfirm-cfg.sh
new file mode 100755
index 000000000000..0c5b0c91016a
--- /dev/null
+++ b/scripts/kconfig/kconfirm/kconfirm-cfg.sh
@@ -0,0 +1,57 @@
+#!/bin/sh
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+#
+# kconfirm is written in Rust, so check that the compiler is available and
+# recent enough. Bindgen is also required because kconfirm uses generated
+# Rust bindings to access the Kconfig parser.
+
+set -eu
+
+min_tool_version=$(dirname "$0")/../../min-tool-version.sh
+
+rustc=${RUSTC:-rustc}
+bindgen=${BINDGEN:-bindgen}
+
+fail()
+{
+ echo >&2 "*"
+ echo >&2 "* $1"
+ echo >&2 "*"
+ echo >&2 "* kconfirm is written in Rust. For instructions on installing"
+ echo >&2 "* the Rust toolchain, see Documentation/rust/quick-start.rst;"
+ echo >&2 "* for kconfirm's requirements, see"
+ echo >&2 "* Documentation/dev-tools/kconfirm.rst."
+ echo >&2 "*"
+ exit 1
+}
+
+if ! command -v "$rustc" >/dev/null; then
+ fail "Unable to find the Rust compiler '$rustc'."
+fi
+
+if ! command -v "$bindgen" >/dev/null; then
+ fail "Unable to find the Rust bindings generator '$bindgen'."
+fi
+
+rustc_version=$(LC_ALL=C "$rustc" --version 2>/dev/null \
+ | sed -nE '1s:.*rustc ([0-9]+\.[0-9]+\.[0-9]+).*:\1:p')
+if [ -z "$rustc_version" ]; then
+ fail "Running '$rustc --version' did not produce an output."
+fi
+
+rustc_min_version=$("$min_tool_version" rustc)
+if ! printf '%s\n%s\n' "$rustc_min_version" "$rustc_version" | sort -CV; then
+ fail "'$rustc' is too old: $rustc_version < $rustc_min_version required."
+fi
+
+bindgen_version=$(LC_ALL=C "$bindgen" --version 2>/dev/null \
+ | sed -nE '1s:.*bindgen ([0-9]+\.[0-9]+\.[0-9]+).*:\1:p')
+if [ -z "$bindgen_version" ]; then
+ fail "Running '$bindgen --version' did not produce an output."
+fi
+
+bindgen_min_version=$("$min_tool_version" bindgen)
+if ! printf '%s\n%s\n' "$bindgen_min_version" "$bindgen_version" | sort -CV; then
+ fail "'$bindgen' is too old: $bindgen_version < $bindgen_min_version required."
+fi
diff --git a/scripts/kconfig/kconfirm/kconfirm.rs b/scripts/kconfig/kconfirm/kconfirm.rs
new file mode 100644
index 000000000000..ceb4dd83c5bb
--- /dev/null
+++ b/scripts/kconfig/kconfirm/kconfirm.rs
@@ -0,0 +1,278 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+//! A static analyzer for Kconfig.
+
+use crate::{
+ analyze::analyze,
+ arch::{
+ arch_dir_to_config,
+ arch_to_srcarch,
+ available_architectures, //
+ },
+ checks::{
+ check_select_visible,
+ check_variable_info,
+ AnalysisArgs,
+ Check, //
+ },
+ kconfig::parse_kconfig,
+ output::{
+ print_findings,
+ Finding, //
+ },
+ symbol_table::SymbolTable, //
+};
+use std::{
+ env,
+ ffi::{
+ CStr,
+ CString,
+ OsString, //
+ },
+ fs,
+ io,
+ path::PathBuf,
+ str::FromStr, //
+};
+
+mod analyze;
+mod arch;
+mod checks;
+mod dead_links;
+#[path = "../kconfig.rs"]
+mod kconfig;
+mod output;
+mod symbol_table;
+
+const USAGE: &str = "\
+Usage: kconfirm --linux-path PATH [OPTION]...
+
+Statically analyze the Kconfig files of a kernel source tree.
+
+Options:
+ -l, --linux-path PATH kernel source tree to analyze
+ -e, --enable-check CHECK,... checks to run on top of the default set
+ -d, --disable-check CHECK,... checks to drop from the default set
+ -k, --kconfig FILE entry Kconfig file (default is the root Kconfig)
+ -h, --help display this help and exit
+
+See Documentation/dev-tools/kconfirm.rst for the list of checks.
+";
+
+/// Runs the enabled checks on the Kconfig parse tree.
+///
+/// # Panics
+///
+/// Panics if traversing the tree encounters an unsupported node or the
+/// parser fails.
+fn check_kconfig(args: &AnalysisArgs, kconfig: &CStr, arch: &str) -> Vec<Finding> {
+ let mut findings = Vec::new();
+ let mut symbol_table = SymbolTable::new();
+ let entries = parse_kconfig(kconfig);
+ findings.extend(analyze(args, &mut symbol_table, arch, entries));
+
+ for (var_symbol, type_info) in &symbol_table.inner {
+ for (_definition_condition, info) in &type_info.attribute_defs {
+ findings.extend(check_variable_info(args, var_symbol, arch, info));
+ }
+
+ if args.is_enabled(Check::SelectVisible) {
+ findings.extend(check_select_visible(var_symbol, type_info, arch));
+ }
+ }
+
+ findings
+}
+
+fn split_csv_arg(dst: &mut Vec<String>, value: &str) {
+ dst.extend(
+ value
+ .split(',')
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string()),
+ );
+}
+
+#[derive(Debug)]
+struct Args {
+ linux_path: PathBuf,
+ kconfig: CString,
+ enable_check: Vec<String>,
+ disable_check: Vec<String>,
+}
+
+fn utf8_argument(argument: OsString) -> Result<String, String> {
+ argument
+ .into_string()
+ .map_err(|argument| format!("argument is not valid UTF-8: {:?}", argument))
+}
+
+/// Returns the value of an option, either the one attached to it with `=` or
+/// packed behind a short name, else the next argument.
+fn option_value(
+ option: &str,
+ attached: Option<String>,
+ arguments: &mut impl Iterator<Item = OsString>,
+) -> Result<String, String> {
+ match attached {
+ Some(value) => Ok(value),
+ None => match arguments.next() {
+ Some(value) => utf8_argument(value),
+ None => Err(format!("{option} requires an argument")),
+ },
+ }
+}
+
+fn parse_args() -> Result<Args, String> {
+ let mut linux_path: Option<PathBuf> = None;
+ let mut kconfig = String::from("Kconfig");
+ let mut enable_check = Vec::new();
+ let mut disable_check = Vec::new();
+
+ let mut arguments = env::args_os().skip(1);
+ while let Some(argument) = arguments.next() {
+ let argument = utf8_argument(argument)?;
+
+ // Accept both `--option value` and `--option=value` for long options,
+ // and both `-o value` and `-ovalue` for short ones.
+ let (option, attached) = if let Some(name) = argument.strip_prefix("--") {
+ match name.split_once('=') {
+ Some((name, value)) => (format!("--{name}"), Some(value.to_string())),
+ None => (argument.clone(), None),
+ }
+ } else if argument.starts_with('-') && argument.len() > 2 {
+ (argument[..2].to_string(), Some(argument[2..].to_string()))
+ } else {
+ (argument.clone(), None)
+ };
+
+ match option.as_str() {
+ "-h" | "--help" => {
+ print!("{USAGE}");
+ std::process::exit(0);
+ }
+ "-l" | "--linux-path" => {
+ let value = option_value(&option, attached, &mut arguments)?;
+ linux_path = Some(PathBuf::from(value));
+ }
+ "-k" | "--kconfig" => {
+ kconfig = option_value(&option, attached, &mut arguments)?;
+ }
+ "-e" | "--enable-check" => {
+ let value = option_value(&option, attached, &mut arguments)?;
+ split_csv_arg(&mut enable_check, &value);
+ }
+ "-d" | "--disable-check" => {
+ let value = option_value(&option, attached, &mut arguments)?;
+ split_csv_arg(&mut disable_check, &value);
+ }
+ _ => return Err(format!("unrecognized option '{option}'")),
+ }
+ }
+
+ let linux_path = linux_path.ok_or("--linux-path is required")?;
+
+ let kconfig = CString::new(kconfig).map_err(|_| "--kconfig contains a NUL byte".to_string())?;
+
+ Ok(Args {
+ linux_path,
+ kconfig,
+ enable_check,
+ disable_check,
+ })
+}
+
+fn source_architecture() -> Result<String, String> {
+ let arch = env::var_os("ARCH").ok_or("ARCH environment variable is required")?;
+ let arch = arch
+ .into_string()
+ .map_err(|arch| format!("ARCH is not valid UTF-8: {arch:?}"))?;
+ if arch.is_empty() {
+ return Err("ARCH environment variable is empty".into());
+ }
+
+ Ok(arch_to_srcarch(&arch).to_owned())
+}
+
+fn main() -> io::Result<()> {
+ let cli_args = parse_args().unwrap_or_else(|e| {
+ eprintln!("error: {e}");
+ eprint!("{USAGE}");
+ std::process::exit(1);
+ });
+ let mut analysis_args = AnalysisArgs::new();
+ for check in [
+ Check::DuplicateDependency,
+ Check::DuplicateRange,
+ Check::DeadRange,
+ Check::DuplicateSelect,
+ Check::DeadSelect,
+ Check::DeadDefault,
+ Check::ConstantCondition,
+ Check::DuplicateDefault,
+ Check::DuplicateImply,
+ Check::DeadImply,
+ Check::ReverseRange,
+ ] {
+ analysis_args.enable_check(check);
+ }
+
+ // `--enable-check`.
+ for name in &cli_args.enable_check {
+ if let Ok(c) = Check::from_str(name) {
+ analysis_args.enable_check(c);
+ } else {
+ eprintln!("Error: check {} does not exist", name);
+ std::process::exit(1);
+ }
+ }
+
+ // `--disable-check`.
+ for name in &cli_args.disable_check {
+ if let Ok(c) = Check::from_str(name) {
+ analysis_args.disable_check(c);
+ } else {
+ eprintln!("Error: check {} does not exist", name);
+ std::process::exit(1);
+ }
+ }
+
+ if analysis_args.is_enabled(Check::DeadLink) {
+ dead_links::check_curl_available().unwrap_or_else(|error| {
+ eprintln!("error: dead_link requires the `curl` command: {error}");
+ std::process::exit(1);
+ });
+ }
+
+ let linux_path = fs::canonicalize(cli_args.linux_path)?;
+ let arch = source_architecture().unwrap_or_else(|error| {
+ eprintln!("error: {error}");
+ std::process::exit(1);
+ });
+ let available_arches = available_architectures(&linux_path)?;
+ if !available_arches.contains(&arch) {
+ eprintln!("Error: unexpected architecture from ARCH, expected one of:");
+ for available_arch in &available_arches {
+ eprint!("{available_arch} ");
+ }
+ eprintln!();
+ std::process::exit(1);
+ }
+
+ env::set_current_dir(&linux_path)?;
+ // SAFETY: kconfirm is single-threaded, so changing its environment cannot
+ // race another environment reader or writer.
+ unsafe {
+ env::set_var("srctree", &linux_path);
+ env::set_var("SRCARCH", &arch);
+ if arch == "um" && env::var_os("HEADER_ARCH").is_none() {
+ env::set_var("HEADER_ARCH", "x86");
+ }
+ }
+ let arch_config = arch_dir_to_config(&arch);
+ let findings = check_kconfig(&analysis_args, &cli_args.kconfig, &arch_config);
+ print_findings(findings);
+ Ok(())
+}
diff --git a/scripts/kconfig/kconfirm/output.rs b/scripts/kconfig/kconfirm/output.rs
new file mode 100644
index 000000000000..dae3c903faca
--- /dev/null
+++ b/scripts/kconfig/kconfirm/output.rs
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+use crate::Check;
+use std::fmt;
+
+/// Diagnostic severity, ordered from most to least severe.
+#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
+pub(crate) enum Severity {
+ Warning,
+ Style,
+}
+
+/// One diagnostic emitted by a Kconfig check.
+#[derive(Debug)]
+pub(crate) struct Finding {
+ pub(crate) severity: Severity,
+ pub(crate) check: Check,
+ pub(crate) symbol: Option<String>,
+ pub(crate) message: String,
+ pub(crate) arch: String,
+}
+
+impl fmt::Display for Finding {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match &self.symbol {
+ Some(s) => write!(
+ f,
+ "{} [{}] [{}] config {}: {}",
+ self.severity,
+ self.check.as_str(),
+ self.arch,
+ s,
+ self.message
+ ),
+ None => write!(
+ f,
+ "{} [{}] [{}] {}",
+ self.severity,
+ self.check.as_str(),
+ self.arch,
+ self.message
+ ),
+ }
+ }
+}
+
+/// Sorts, deduplicates, and prints findings.
+pub(crate) fn print_findings(mut findings: Vec<Finding>) {
+ findings.sort_by(|a, b| {
+ (
+ &a.severity,
+ a.check.as_str(),
+ &a.symbol,
+ &a.message,
+ &a.arch,
+ )
+ .cmp(&(
+ &b.severity,
+ b.check.as_str(),
+ &b.symbol,
+ &b.message,
+ &b.arch,
+ ))
+ });
+ findings.dedup_by(|a, b| {
+ a.severity == b.severity
+ && a.check.as_str() == b.check.as_str()
+ && a.symbol == b.symbol
+ && a.message == b.message
+ && a.arch == b.arch
+ });
+
+ for finding in findings {
+ println!("{finding}");
+ }
+}
+
+impl fmt::Display for Severity {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ match self {
+ Severity::Warning => write!(f, "WARNING"),
+ Severity::Style => write!(f, "STYLE "),
+ }
+ }
+}
diff --git a/scripts/kconfig/kconfirm/symbol_table.rs b/scripts/kconfig/kconfirm/symbol_table.rs
new file mode 100644
index 000000000000..0b2cc1e296d5
--- /dev/null
+++ b/scripts/kconfig/kconfirm/symbol_table.rs
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: GPL-2.0-only
+/*
+ * Copyright (c) 2026, Julian Braha <julianbraha@gmail.com>
+ */
+use crate::kconfig::{
+ DefaultAttribute,
+ Expression,
+ Range, //
+};
+use std::collections::{
+ hash_map,
+ HashMap, //
+};
+
+type KconfigSymbol = String;
+type Cond = Option<Expression>;
+
+/// Collected analysis information for one Kconfig symbol.
+#[derive(Debug, Clone)]
+pub(crate) struct SymbolInfo {
+ /// Selectors of this option (and their select-conditions).
+ pub(crate) selected_by: HashMap<KconfigSymbol, Vec<Cond>>,
+ /// Each partial or complete definition and their nesting conditions.
+ pub(crate) attribute_defs: Vec<(Vec<Expression>, AttributeDef)>,
+}
+
+/// The attributes of one partial or complete symbol definition.
+#[derive(Debug, Clone, Default)]
+pub(crate) struct AttributeDef {
+ pub(crate) dependencies: Vec<Expression>,
+ pub(crate) ranges: Vec<Range>,
+ pub(crate) defaults: Vec<DefaultAttribute>,
+ pub(crate) visibility: Vec<Option<Expression>>,
+ pub(crate) selects: Vec<(KconfigSymbol, Cond)>,
+ pub(crate) implies: Vec<(KconfigSymbol, Cond)>,
+}
+
+/// One symbol-table update produced while traversing a Kconfig entry.
+pub(crate) struct SymbolUpdate {
+ pub(crate) symbol: KconfigSymbol,
+ pub(crate) is_definition: bool,
+ pub(crate) attributes: AttributeDef,
+ pub(crate) definition_condition: Vec<Expression>,
+ pub(crate) selected_by: Option<(KconfigSymbol, Cond)>,
+}
+
+impl SymbolInfo {
+ fn new_empty() -> Self {
+ Self {
+ selected_by: HashMap::new(),
+ attribute_defs: Vec::new(),
+ }
+ }
+
+ fn insert(&mut self, update: SymbolUpdate) {
+ let SymbolUpdate {
+ symbol: _,
+ is_definition,
+ attributes,
+ definition_condition,
+ selected_by,
+ } = update;
+
+ if let Some((selector, condition)) = selected_by {
+ self.selected_by
+ .entry(selector)
+ .or_default()
+ .push(condition);
+ }
+
+ if is_definition {
+ self.attribute_defs.push((definition_condition, attributes));
+ }
+ }
+}
+
+/// The symbol table stores the definitions of each encountered config option.
+pub(crate) struct SymbolTable {
+ pub(crate) inner: HashMap<KconfigSymbol, SymbolInfo>,
+}
+
+impl SymbolTable {
+ pub(crate) fn new() -> Self {
+ SymbolTable {
+ inner: HashMap::new(),
+ }
+ }
+
+ pub(crate) fn merge_insert(&mut self, update: SymbolUpdate) {
+ let entry = self.inner.entry(update.symbol.clone());
+
+ match entry {
+ hash_map::Entry::Vacant(v) => {
+ let mut t = SymbolInfo::new_empty();
+ t.insert(update);
+ v.insert(t);
+ }
+
+ hash_map::Entry::Occupied(mut o) => {
+ let t = o.get_mut();
+ t.insert(update);
+ }
+ }
+ }
+}
--
2.54.0
^ permalink raw reply [flat|nested] 9+ messages in thread* [PATCH v4 3/5] kconfirm: add tests
2026-07-27 0:16 [PATCH v4 0/5] add kconfirm Julian Braha
2026-07-27 0:16 ` [PATCH v4 1/5] kconfig: add add another callback to the parser to view raw parse tree Julian Braha
2026-07-27 0:16 ` [PATCH 2/5] kconfig: add kconfirm Julian Braha
@ 2026-07-27 0:16 ` Julian Braha
2026-07-27 0:16 ` [PATCH v4 4/5] Documentation: add kconfirm Julian Braha
` (2 subsequent siblings)
5 siblings, 0 replies; 9+ messages in thread
From: Julian Braha @ 2026-07-27 0:16 UTC (permalink / raw)
To: nathan, nsc
Cc: ojeda, akpm, jani.nikula, gary, gregkh, arnd, ljs, andrew.jones,
masahiroy, corbet, qingfang.deng, demiobenour, ej, linux-kernel,
rust-for-linux, linux-doc, linux-kbuild, Julian Braha
Add tests for kconfirm using pytest, as Kconfig does. The tests cover all
of kconfirm’s checks, and any additional, future checks need to add tests
as well.
Assisted-by: Claude:claude-fable-5
Signed-off-by: Julian Braha <julianbraha@gmail.com>
---
.../kconfig/kconfirm/tests/arch/arm/Kconfig | 9 +
.../kconfirm/tests/arch/powerpc/Kconfig | 4 +
.../kconfig/kconfirm/tests/arch/riscv/Kconfig | 9 +
.../kconfig/kconfirm/tests/arch/sh/Kconfig | 4 +
.../kconfirm/tests/arch/testarch/Kconfig | 4 +
.../kconfig/kconfirm/tests/arch/um/Kconfig | 4 +
.../kconfig/kconfirm/tests/arch/x86/Kconfig | 4 +
.../kconfirm/tests/architecture.Kconfig | 4 +
.../tests/architecture_common.Kconfig | 19 +
.../kconfirm/tests/conditional_prompt.Kconfig | 17 +
scripts/kconfig/kconfirm/tests/conftest.py | 93 +++++
...nt_condition_negative_expression_1.Kconfig | 13 +
...nt_condition_negative_expression_2.Kconfig | 13 +
...nstant_condition_negative_symbol_1.Kconfig | 13 +
...nstant_condition_negative_symbol_2.Kconfig | 13 +
.../kconfig/kconfirm/tests/dead_link.Kconfig | 12 +
.../tests/default_categorization.Kconfig | 20 +
scripts/kconfig/kconfirm/tests/pytest.ini | 2 +
scripts/kconfig/kconfirm/tests/ranges.Kconfig | 39 ++
.../kconfirm/tests/select_imply.Kconfig | 28 ++
.../kconfig/kconfirm/tests/test_kconfirm.py | 358 ++++++++++++++++++
21 files changed, 682 insertions(+)
create mode 100644 scripts/kconfig/kconfirm/tests/arch/arm/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/arch/powerpc/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/arch/riscv/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/arch/sh/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/arch/testarch/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/arch/um/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/arch/x86/Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/architecture.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/architecture_common.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/conditional_prompt.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/conftest.py
create mode 100644 scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_1.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_2.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_1.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_2.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/dead_link.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/default_categorization.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/pytest.ini
create mode 100644 scripts/kconfig/kconfirm/tests/ranges.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/select_imply.Kconfig
create mode 100644 scripts/kconfig/kconfirm/tests/test_kconfirm.py
diff --git a/scripts/kconfig/kconfirm/tests/arch/arm/Kconfig b/scripts/kconfig/kconfirm/tests/arch/arm/Kconfig
new file mode 100644
index 000000000000..5922782a5f4d
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/arm/Kconfig
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "architecture_common.Kconfig"
+
+config ARM_ONLY
+ bool
+ depends on ARM_PREREQUISITE
+ depends on ARM_PREREQUISITE
diff --git a/scripts/kconfig/kconfirm/tests/arch/powerpc/Kconfig b/scripts/kconfig/kconfirm/tests/arch/powerpc/Kconfig
new file mode 100644
index 000000000000..45e94dc6cec6
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/powerpc/Kconfig
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "architecture_common.Kconfig"
diff --git a/scripts/kconfig/kconfirm/tests/arch/riscv/Kconfig b/scripts/kconfig/kconfirm/tests/arch/riscv/Kconfig
new file mode 100644
index 000000000000..9f760f673064
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/riscv/Kconfig
@@ -0,0 +1,9 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "architecture_common.Kconfig"
+
+config RISCV_ONLY
+ bool
+ depends on RISCV_PREREQUISITE
+ depends on RISCV_PREREQUISITE
diff --git a/scripts/kconfig/kconfirm/tests/arch/sh/Kconfig b/scripts/kconfig/kconfirm/tests/arch/sh/Kconfig
new file mode 100644
index 000000000000..45e94dc6cec6
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/sh/Kconfig
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "architecture_common.Kconfig"
diff --git a/scripts/kconfig/kconfirm/tests/arch/testarch/Kconfig b/scripts/kconfig/kconfirm/tests/arch/testarch/Kconfig
new file mode 100644
index 000000000000..bb5c0e4b3829
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/testarch/Kconfig
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+# Dynamically discovered test architecture.
diff --git a/scripts/kconfig/kconfirm/tests/arch/um/Kconfig b/scripts/kconfig/kconfirm/tests/arch/um/Kconfig
new file mode 100644
index 000000000000..45e94dc6cec6
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/um/Kconfig
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "architecture_common.Kconfig"
diff --git a/scripts/kconfig/kconfirm/tests/arch/x86/Kconfig b/scripts/kconfig/kconfirm/tests/arch/x86/Kconfig
new file mode 100644
index 000000000000..45e94dc6cec6
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/arch/x86/Kconfig
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "architecture_common.Kconfig"
diff --git a/scripts/kconfig/kconfirm/tests/architecture.Kconfig b/scripts/kconfig/kconfirm/tests/architecture.Kconfig
new file mode 100644
index 000000000000..a4f1c68548b4
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/architecture.Kconfig
@@ -0,0 +1,4 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+source "arch/$(SRCARCH)/Kconfig"
diff --git a/scripts/kconfig/kconfirm/tests/architecture_common.Kconfig b/scripts/kconfig/kconfirm/tests/architecture_common.Kconfig
new file mode 100644
index 000000000000..3205d0d59071
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/architecture_common.Kconfig
@@ -0,0 +1,19 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config SHARED_SELECTEE
+ bool "Shared target"
+ help
+ This is a test-only symbol with a visible prompt.
+ Kconfirm uses it as the target of a select statement.
+ The architecture tests verify that selecting this symbol
+ produces the expected select-visible diagnostic.
+
+config SHARED_SELECTOR
+ bool
+ select SHARED_SELECTEE
+
+config SHARED_DEPENDENCY
+ bool
+ depends on SHARED_PREREQUISITE
+ depends on SHARED_PREREQUISITE
diff --git a/scripts/kconfig/kconfirm/tests/conditional_prompt.Kconfig b/scripts/kconfig/kconfirm/tests/conditional_prompt.Kconfig
new file mode 100644
index 000000000000..f2fef6ea874b
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/conditional_prompt.Kconfig
@@ -0,0 +1,17 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config SELECTEE
+ bool "Target" if PROMPT_CONDITION
+ help
+ This is a test-only symbol with a conditional prompt.
+ Kconfirm uses it as the target of a select statement.
+ The test verifies that the prompt condition is included
+ when deciding whether the target is visible.
+
+config SELECTOR
+ bool
+ select SELECTEE
+
+config PROMPT_CONDITION
+ bool
diff --git a/scripts/kconfig/kconfirm/tests/conftest.py b/scripts/kconfig/kconfirm/tests/conftest.py
new file mode 100644
index 000000000000..005681e7e337
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/conftest.py
@@ -0,0 +1,93 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+"""Fixtures for kconfirm regression tests."""
+
+import os
+import subprocess
+from pathlib import Path
+
+import pytest
+
+
+class Kconfirm:
+ """Test kconfirm against Kconfig fixtures."""
+
+ def __init__(self, binary):
+ self._binary = Path(binary).resolve()
+ self._test_dir = Path(__file__).resolve().parent
+
+ def run_cli(self, arguments, *, arch="x86", environment=None,
+ should_succeed=True):
+ """Run kconfirm with an explicit argument list."""
+ command = [str(self._binary), *arguments]
+
+ process_environment = os.environ.copy()
+ process_environment.pop("SRCARCH", None)
+ process_environment.pop("HEADER_ARCH", None)
+ if arch is None:
+ process_environment.pop("ARCH", None)
+ else:
+ process_environment["ARCH"] = arch
+ if environment:
+ process_environment.update(environment)
+
+ process = subprocess.run(
+ command,
+ capture_output=True,
+ cwd=self._test_dir,
+ env=process_environment,
+ check=False,
+ text=True,
+ )
+
+ print("[command]\n{}\n".format(" ".join(command)))
+ print("[retcode]\n{}\n".format(process.returncode))
+ print("[stdout]\n{}".format(process.stdout))
+ print("[stderr]\n{}".format(process.stderr))
+
+ if should_succeed:
+ assert process.returncode == 0
+ else:
+ assert process.returncode != 0
+
+ return process
+
+ def run(self, kconfig, *, arch="x86", enable_check=None,
+ disable_check=None, environment=None, should_succeed=True):
+ """Run kconfirm against one Kconfig fixture."""
+ arguments = [
+ "--linux-path",
+ str(self._test_dir),
+ "--kconfig",
+ kconfig,
+ ]
+
+ if enable_check:
+ arguments.extend(["--enable-check", enable_check])
+ if disable_check:
+ arguments.extend(["--disable-check", disable_check])
+
+ return self.run_cli(
+ arguments,
+ arch=arch,
+ environment=environment,
+ should_succeed=should_succeed,
+ )
+
+
+def pytest_addoption(parser):
+ parser.addoption(
+ "--kconfirm",
+ metavar="PATH",
+ help="path to the kconfirm executable",
+ )
+
+
+@pytest.fixture(scope="session")
+def kconfirm(request):
+ binary = request.config.getoption("--kconfirm")
+ if not binary:
+ raise pytest.UsageError("--kconfirm is required")
+
+ return Kconfirm(binary)
diff --git a/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_1.Kconfig b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_1.Kconfig
new file mode 100644
index 000000000000..3e3cc1c72ecf
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_1.Kconfig
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config A
+ tristate
+ depends on !(B || C)
+ default y if B || C
+
+config B
+ bool
+
+config C
+ tristate
diff --git a/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_2.Kconfig b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_2.Kconfig
new file mode 100644
index 000000000000..64a1778b2981
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_expression_2.Kconfig
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config A
+ tristate
+ depends on B || C
+ default y if !(B || C)
+
+config B
+ bool
+
+config C
+ tristate
diff --git a/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_1.Kconfig b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_1.Kconfig
new file mode 100644
index 000000000000..34afd2a48396
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_1.Kconfig
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config A
+ tristate
+ depends on B
+ default y if !B
+
+config B
+ bool
+
+config C
+ tristate
diff --git a/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_2.Kconfig b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_2.Kconfig
new file mode 100644
index 000000000000..d578b130faab
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/constant_condition/default_constant_condition_negative_symbol_2.Kconfig
@@ -0,0 +1,13 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config A
+ tristate
+ depends on !B
+ default y if B
+
+config B
+ bool
+
+config C
+ tristate
diff --git a/scripts/kconfig/kconfirm/tests/dead_link.Kconfig b/scripts/kconfig/kconfirm/tests/dead_link.Kconfig
new file mode 100644
index 000000000000..29bce7aa30fc
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/dead_link.Kconfig
@@ -0,0 +1,12 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config HAS_DEAD_LINK
+ bool
+ help
+ The obsolete documentation was at https://example.invalid/obsolete.
+
+config HAS_DUPLICATE_DEAD_LINK
+ bool
+ help
+ The same documentation was at https://example.invalid/obsolete.
diff --git a/scripts/kconfig/kconfirm/tests/default_categorization.Kconfig b/scripts/kconfig/kconfirm/tests/default_categorization.Kconfig
new file mode 100644
index 000000000000..45ff769ad1c8
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/default_categorization.Kconfig
@@ -0,0 +1,20 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+config DUPLICATE_UNCONDITIONAL
+ bool
+ default y
+ default y
+
+config DEAD_AFTER_UNCONDITIONAL
+ bool
+ default y
+ default n
+
+config DUPLICATE_CONDITIONAL
+ bool
+ default y if CONDITION
+ default y if CONDITION
+
+config CONDITION
+ bool
diff --git a/scripts/kconfig/kconfirm/tests/pytest.ini b/scripts/kconfig/kconfirm/tests/pytest.ini
new file mode 100644
index 000000000000..2e24f7bc9f2c
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/pytest.ini
@@ -0,0 +1,2 @@
+[pytest]
+addopts = --verbose
diff --git a/scripts/kconfig/kconfirm/tests/ranges.Kconfig b/scripts/kconfig/kconfirm/tests/ranges.Kconfig
new file mode 100644
index 000000000000..4ced953247e1
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/ranges.Kconfig
@@ -0,0 +1,39 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+#
+# Fixture for toggling duplicate_range and dead_range independently of each
+# other, and for categorizing repeated ranges correctly.
+
+config GATE
+ bool
+
+config OTHER_GATE
+ bool
+
+config RANGED_DUP
+ int
+ range 1 5
+ range 1 5
+
+config RANGED_DEAD
+ int
+ range 1 5
+ range 2 6 if GATE
+
+config RANGED_CONDITIONAL_FIRST
+ int
+ range 1 5 if GATE
+ range 2 6
+
+config RANGED_DEAD_UNCONDITIONAL
+ int
+ range 1 5
+ range 2 6
+
+config RANGED_MULTIPLE
+ int
+ range 1 5 if GATE
+ range 2 6 if OTHER_GATE
+ range 3 7
+ range 4 8 if GATE
+ range 5 9
diff --git a/scripts/kconfig/kconfirm/tests/select_imply.Kconfig b/scripts/kconfig/kconfirm/tests/select_imply.Kconfig
new file mode 100644
index 000000000000..ac8f2aa1b66c
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/select_imply.Kconfig
@@ -0,0 +1,28 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+#
+# Fixture for toggling duplicate_select/dead_select and
+# duplicate_imply/dead_imply independently of each other.
+
+config SELECTEE
+ bool
+
+config OTHER_SELECTEE
+ bool
+
+config GATE
+ bool
+
+config SELECTOR
+ bool
+ select SELECTEE
+ select SELECTEE if GATE
+ select OTHER_SELECTEE
+ select OTHER_SELECTEE
+
+config IMPLIER
+ bool
+ imply SELECTEE
+ imply SELECTEE if GATE
+ imply OTHER_SELECTEE
+ imply OTHER_SELECTEE
diff --git a/scripts/kconfig/kconfirm/tests/test_kconfirm.py b/scripts/kconfig/kconfirm/tests/test_kconfirm.py
new file mode 100644
index 000000000000..5e23f7d2b0cb
--- /dev/null
+++ b/scripts/kconfig/kconfirm/tests/test_kconfirm.py
@@ -0,0 +1,358 @@
+# SPDX-License-Identifier: GPL-2.0-only
+# Copyright (C) 2026 Julian Braha <julianbraha@gmail.com>
+
+"""Regression tests for kconfirm."""
+
+import shutil
+from pathlib import Path
+
+import pytest
+
+
+TEST_DIR = Path(__file__).resolve().parent
+
+
+@pytest.mark.parametrize(
+ "fixture",
+ [
+ "default_constant_condition_negative_expression_1.Kconfig",
+ "default_constant_condition_negative_expression_2.Kconfig",
+ "default_constant_condition_negative_symbol_1.Kconfig",
+ "default_constant_condition_negative_symbol_2.Kconfig",
+ ],
+)
+def test_negative_constant_condition(kconfirm, fixture):
+ process = kconfirm.run("constant_condition/{}".format(fixture))
+
+ assert "[constant_condition] [X86] config A:" in process.stdout
+ assert "will always be false" in process.stdout
+
+
+def test_default_categorization(kconfirm):
+ process = kconfirm.run("default_categorization.Kconfig")
+
+ assert (
+ "[duplicate_default] [X86] config DUPLICATE_UNCONDITIONAL: "
+ "duplicate default of y"
+ ) in process.stdout
+ assert (
+ "[dead_default] [X86] config DUPLICATE_UNCONDITIONAL:"
+ ) not in process.stdout
+ assert (
+ "[dead_default] [X86] config DEAD_AFTER_UNCONDITIONAL: "
+ "dead default of n"
+ ) in process.stdout
+ assert (
+ "[duplicate_default] [X86] config DUPLICATE_CONDITIONAL: "
+ "duplicate default of y"
+ ) in process.stdout
+
+
+def test_select_and_imply_checks(kconfirm):
+ process = kconfirm.run("select_imply.Kconfig")
+
+ assert (
+ "[dead_select] [X86] config SELECTOR: dead select of SELECTEE"
+ ) in process.stdout
+ assert (
+ "[dead_imply] [X86] config IMPLIER: dead imply of SELECTEE"
+ ) in process.stdout
+
+
+def test_disable_dead_select_and_imply_checks(kconfirm):
+ process = kconfirm.run(
+ "select_imply.Kconfig",
+ disable_check="dead_select,dead_imply",
+ )
+
+ assert "[dead_select]" not in process.stdout
+ assert "[dead_imply]" not in process.stdout
+ assert (
+ "[duplicate_select] [X86] config SELECTOR: "
+ "duplicate select of OTHER_SELECTEE"
+ ) in process.stdout
+ assert (
+ "[duplicate_imply] [X86] config IMPLIER: "
+ "duplicate imply of OTHER_SELECTEE"
+ ) in process.stdout
+
+
+def test_disable_duplicate_select_and_imply_checks(kconfirm):
+ process = kconfirm.run(
+ "select_imply.Kconfig",
+ disable_check="duplicate_select,duplicate_imply",
+ )
+
+ assert "[duplicate_select]" not in process.stdout
+ assert "[duplicate_imply]" not in process.stdout
+ assert (
+ "[dead_select] [X86] config SELECTOR: dead select of SELECTEE"
+ ) in process.stdout
+ assert (
+ "[dead_imply] [X86] config IMPLIER: dead imply of SELECTEE"
+ ) in process.stdout
+
+
+def test_range_checks(kconfirm):
+ process = kconfirm.run("ranges.Kconfig")
+
+ assert (
+ "[duplicate_range] [X86] config RANGED_DUP: duplicate range 1 5"
+ ) in process.stdout
+ assert "[dead_range] [X86] config RANGED_DUP:" not in process.stdout
+ assert (
+ "[dead_range] [X86] config RANGED_DEAD: dead range of 2 6"
+ ) in process.stdout
+ assert (
+ "[dead_range] [X86] config RANGED_DEAD_UNCONDITIONAL: "
+ "dead range of 2 6"
+ ) in process.stdout
+ assert (
+ "[dead_range] [X86] config RANGED_CONDITIONAL_FIRST:"
+ ) not in process.stdout
+ assert process.stdout.count(
+ "[dead_range] [X86] config RANGED_MULTIPLE:"
+ ) == 2
+
+
+def test_disable_dead_range_check(kconfirm):
+ process = kconfirm.run(
+ "ranges.Kconfig",
+ disable_check="dead_range",
+ )
+
+ assert "[dead_range]" not in process.stdout
+ assert (
+ "[duplicate_range] [X86] config RANGED_DUP: duplicate range 1 5"
+ ) in process.stdout
+
+
+def test_disable_duplicate_range_check(kconfirm):
+ process = kconfirm.run(
+ "ranges.Kconfig",
+ disable_check="duplicate_range",
+ )
+
+ assert "[duplicate_range]" not in process.stdout
+ assert (
+ "[dead_range] [X86] config RANGED_DEAD: dead range of 2 6"
+ ) in process.stdout
+
+
+def test_architecture_directory_is_discovered(kconfirm):
+ process = kconfirm.run(
+ "default_categorization.Kconfig",
+ arch="testarch",
+ )
+
+ assert (
+ "[duplicate_default] [TESTARCH] config DUPLICATE_UNCONDITIONAL:"
+ ) in process.stdout
+
+
+def test_unknown_architecture_is_rejected(kconfirm):
+ process = kconfirm.run(
+ "conditional_prompt.Kconfig",
+ arch="missing",
+ should_succeed=False,
+ )
+ output = process.stdout + process.stderr
+
+ assert "unexpected architecture from ARCH" in output
+ for architecture in ["arm", "powerpc", "riscv", "sh", "testarch", "um", "x86"]:
+ assert architecture in output
+
+
+def test_missing_architecture_is_rejected(kconfirm):
+ process = kconfirm.run(
+ "conditional_prompt.Kconfig",
+ arch=None,
+ should_succeed=False,
+ )
+
+ assert "ARCH environment variable is required" in process.stderr
+
+
+def test_conditional_prompt_is_not_select_visible(kconfirm):
+ process = kconfirm.run(
+ "conditional_prompt.Kconfig",
+ enable_check="select_visible",
+ )
+
+ assert "[select_visible]" not in process.stdout
+
+
+@pytest.mark.parametrize(
+ ("arch", "arch_config", "specific_symbol", "specific_dependency"),
+ [
+ ("arm", "ARM", "ARM_ONLY", "ARM_PREREQUISITE"),
+ ("riscv", "RISCV", "RISCV_ONLY", "RISCV_PREREQUISITE"),
+ ],
+)
+def test_architecture_findings(
+ kconfirm,
+ arch,
+ arch_config,
+ specific_symbol,
+ specific_dependency,
+):
+ process = kconfirm.run(
+ "architecture.Kconfig",
+ arch=arch,
+ enable_check="select_visible",
+ )
+
+ assert (
+ f"[duplicate_dependency] [{arch_config}] config SHARED_DEPENDENCY: "
+ "duplicate dependency on SHARED_PREREQUISITE"
+ ) in process.stdout
+ assert (
+ f"[select_visible] [{arch_config}] config SHARED_SELECTOR: "
+ "selects the visible SHARED_SELECTEE"
+ ) in process.stdout
+ assert (
+ f"[duplicate_dependency] [{arch_config}] config {specific_symbol}: "
+ f"duplicate dependency on {specific_dependency}"
+ ) in process.stdout
+
+
+@pytest.mark.parametrize(
+ ("arch", "arch_config"),
+ [
+ ("x86_64", "X86"),
+ ("powerpc", "PPC"),
+ ("sh", "SUPERH"),
+ ("um", "UML"),
+ ],
+)
+def test_architecture_alias_selects_source_directory(kconfirm, arch, arch_config):
+ process = kconfirm.run(
+ "architecture.Kconfig",
+ arch=arch,
+ enable_check="select_visible",
+ )
+
+ assert (
+ f"[duplicate_dependency] [{arch_config}] config SHARED_DEPENDENCY: "
+ "duplicate dependency on SHARED_PREREQUISITE"
+ ) in process.stdout
+
+
+@pytest.mark.skipif(shutil.which("curl") is None, reason="requires curl")
+def test_dead_links_are_deduplicated(kconfirm):
+ # Both fixture config options point at the same example.invalid URL. That
+ # TLD is reserved and never resolves (RFC 6761), so curl should fail
+ # deterministically, and the link reported once. The second config option
+ # referencing it should be deduplicated.
+ process = kconfirm.run(
+ "dead_link.Kconfig",
+ enable_check="dead_link",
+ )
+
+ assert (
+ "[dead_link] [X86] config HAS_DEAD_LINK:"
+ ) in process.stdout
+ assert (
+ "[dead_link] [X86] config HAS_DUPLICATE_DEAD_LINK:"
+ ) not in process.stdout
+
+
+def test_dead_link_requires_curl(kconfirm):
+ process = kconfirm.run(
+ "dead_link.Kconfig",
+ enable_check="dead_link",
+ environment={"PATH": ""},
+ should_succeed=False,
+ )
+
+ assert "dead_link requires the `curl` command" in process.stderr
+
+
+@pytest.mark.parametrize(
+ "arguments",
+ [
+ [
+ f"--linux-path={TEST_DIR}",
+ "--kconfig=conditional_prompt.Kconfig",
+ "--enable-check=select_visible",
+ "--disable-check=dead_range",
+ ],
+ [
+ f"-l{TEST_DIR}",
+ "-kconditional_prompt.Kconfig",
+ "-eselect_visible",
+ "-ddead_range",
+ ],
+ ],
+)
+def test_attached_option_values(kconfirm, arguments):
+ process = kconfirm.run_cli(arguments)
+
+ assert "[select_visible]" not in process.stdout
+
+
+def test_repeated_check_options(kconfirm):
+ process = kconfirm.run_cli(
+ [
+ "--linux-path",
+ str(TEST_DIR),
+ "--kconfig",
+ "conditional_prompt.Kconfig",
+ "--enable-check",
+ "select_visible",
+ "--enable-check",
+ "duplicate_default_value",
+ "--disable-check",
+ "dead_range",
+ "--disable-check",
+ "reverse_range",
+ ],
+ )
+
+ assert process.stderr == ""
+
+
+def test_help(kconfirm):
+ process = kconfirm.run_cli(["--help"])
+
+ assert "Usage: kconfirm --linux-path PATH" in process.stdout
+
+
+@pytest.mark.parametrize(
+ ("arguments", "message"),
+ [
+ ([], "--linux-path is required"),
+ (["--unknown"], "unrecognized option '--unknown'"),
+ (
+ [
+ "--linux-path",
+ str(TEST_DIR),
+ "--enable-check",
+ "not_a_check",
+ ],
+ "check not_a_check does not exist",
+ ),
+ (
+ [
+ "--linux-path",
+ str(TEST_DIR),
+ "--enable-check",
+ "ungrouped_attribute",
+ ],
+ "check ungrouped_attribute does not exist",
+ ),
+ ],
+)
+def test_cli_errors(kconfirm, arguments, message):
+ process = kconfirm.run_cli(arguments, should_succeed=False)
+
+ assert message in process.stdout + process.stderr
+
+
+def test_invalid_linux_path(kconfirm):
+ process = kconfirm.run_cli(
+ ["--linux-path", str(TEST_DIR / "does-not-exist")],
+ should_succeed=False,
+ )
+
+ assert "No such file or directory" in process.stderr
--
2.54.0
^ permalink raw reply [flat|nested] 9+ messages in thread