diff --git a/vhdl_lang/src/ast/search.rs b/vhdl_lang/src/ast/search.rs
index 37d9ed7a..dc7916c7 100644
--- a/vhdl_lang/src/ast/search.rs
+++ b/vhdl_lang/src/ast/search.rs
@@ -508,6 +508,24 @@ impl Search for SensitivityList {
     }
 }
 
+impl Search for ProcessStatement {
+    fn search(&self, ctx: &dyn TokenAccess, searcher: &mut impl Searcher) -> SearchResult {
+        let ProcessStatement {
+            postponed: _,
+            sensitivity_list,
+            decl,
+            statements,
+            end_label_pos: _,
+            ..
+        } = self;
+        if let Some(sensitivity_list) = sensitivity_list {
+            return_if_found!(sensitivity_list.item.search(ctx, searcher));
+        }
+        return_if_found!(decl.search(ctx, searcher));
+        statements.search(ctx, searcher)
+    }
+}
+
 impl Search for LabeledConcurrentStatement {
     fn search(&self, ctx: &dyn TokenAccess, searcher: &mut impl Searcher) -> SearchResult {
         return_if_found!(searcher
@@ -523,19 +541,7 @@ impl Search for LabeledConcurrentStatement {
                 return_if_found!(block.statements.search(ctx, searcher));
             }
             ConcurrentStatement::Process(ref process) => {
-                let ProcessStatement {
-                    postponed: _,
-                    sensitivity_list,
-                    decl,
-                    statements,
-                    end_label_pos: _,
-                    ..
-                } = process;
-                if let Some(sensitivity_list) = sensitivity_list {
-                    return_if_found!(sensitivity_list.item.search(ctx, searcher));
-                }
-                return_if_found!(decl.search(ctx, searcher));
-                return_if_found!(statements.search(ctx, searcher));
+                return_if_found!(process.search(ctx, searcher));
             }
             ConcurrentStatement::ForGenerate(ref gen) => {
                 return_if_found!(searcher
diff --git a/vhdl_lang/src/ast/util.rs b/vhdl_lang/src/ast/util.rs
index 3eea7790..21ff904e 100644
--- a/vhdl_lang/src/ast/util.rs
+++ b/vhdl_lang/src/ast/util.rs
@@ -339,6 +339,19 @@ impl Name {
         }
     }
 
+    /// Like [self.get_suffix_reference], but disregards final indexes, such as
+    /// `foo.bar.baz(0)`
+    pub fn get_suffix_reference_disregard_index(&self) -> Option<EntityId> {
+        use Name::*;
+        match self {
+            Designator(suffix) => suffix.reference.get(),
+            Selected(_, suffix) => suffix.item.reference.get(),
+            Slice(name, _) => name.item.get_suffix_reference_disregard_index(),
+            CallOrIndexed(coi) => coi.name.item.get_suffix_reference_disregard_index(),
+            _ => None,
+        }
+    }
+
     pub fn prefix(&self) -> Option<&Designator> {
         match self {
             Self::Attribute(attr) => attr.name.item.prefix(),
diff --git a/vhdl_lang/src/data/error_codes.rs b/vhdl_lang/src/data/error_codes.rs
index 3eb776a0..4efad56d 100644
--- a/vhdl_lang/src/data/error_codes.rs
+++ b/vhdl_lang/src/data/error_codes.rs
@@ -410,6 +410,33 @@ pub enum ErrorCode {
     /// ```
     UnassociatedContext,
 
+    /// A signal is missing in the sensitivity list
+    ///
+    /// # Example
+    /// ```vhdl
+    /// foo: process(y) is
+    /// begin
+    ///     bar <= x;
+    /// end;
+    /// ```
+    ///
+    /// The signal `x` is read by the process `foo`, but it is not part
+    /// of the sensitivity list.
+    MissingInSensitivityList,
+
+    /// A signal is present in a sensitivity list, but never used
+    ///
+    /// # Example
+    /// ```vhdl
+    /// foo: process(x, y) is
+    /// begin
+    ///     bar <= x;
+    /// end;
+    /// ```
+    ///
+    /// Both signals `x` and `y` are specified in the process, but only x is read.
+    SuperfluousInSensitivityList,
+
     // Misc
     /// An internal error that signifies that some precondition within vhdl_lang wasn't met.
     /// If an error with this error code occurs,
@@ -494,7 +521,9 @@ impl Default for SeverityMap {
             | InvalidCall => Some(Error),
             Unused
             | UnnecessaryWorkLibrary
-            | UnassociatedContext => Some(Warning),
+            | UnassociatedContext
+            | MissingInSensitivityList
+            | SuperfluousInSensitivityList => Some(Warning),
             Internal => Some(Error),
             Related => Some(Hint)
         };
diff --git a/vhdl_lang/src/lint.rs b/vhdl_lang/src/lint.rs
index 2c2ad1f6..efd361c1 100644
--- a/vhdl_lang/src/lint.rs
+++ b/vhdl_lang/src/lint.rs
@@ -5,3 +5,4 @@
 // Copyright (c) 2022, Olof Kraigher olof.kraigher@gmail.com
 
 pub mod dead_code;
+pub mod sensitivity_list;
diff --git a/vhdl_lang/src/lint/sensitivity_list.rs b/vhdl_lang/src/lint/sensitivity_list.rs
new file mode 100644
index 00000000..e7ed086e
--- /dev/null
+++ b/vhdl_lang/src/lint/sensitivity_list.rs
@@ -0,0 +1,850 @@
+// This Source Code Form is subject to the terms of the Mozilla Public
+// License, v. 2.0. If a copy of the MPL was not distributed with this file,
+// You can obtain one at http://mozilla.org/MPL/2.0/.
+//
+// Copyright (c) 2024, Lukas Scheller lukasscheller@icloud.com
+//! lints to check for issues in the sensitivity lists of process statements.
+//! The checks include the following:
+//! - Checking for superfluous signals in the sensitivity lists of combinational processes
+//! - Checking for missing signals in the sensitivity lists of combinational processes
+//!
+//! The [SensitivityListLinter] struct is the main entry point to the linting procedure
+//! and is used to cache already checked processes. This avoids checking every process on every
+//! analysis step.
+//!
+//! Whether a process is considered combinational is based on simple heuristics that are roughly
+//! equivalent to the following:
+//! - If the process contains a single `if` statement containing a `rising_edge(clk)` or `clk'event`
+//!   signal, the process is considered sequential
+//! - The same is true, if the aforementioned condition applies to an `elsif` branch
+//! - otherwise, the process is considered combinational.
+
+use crate::analysis::{DesignRoot, LockedUnit};
+use crate::ast::search::{
+    DeclarationItem, FoundDeclaration, Search, SearchResult, SearchState, Searcher,
+};
+use crate::ast::token_range::WithTokenSpan;
+use crate::ast::{
+    ActualPart, Allocator, AssignmentRightHand, AttributeDesignator, AttributeName,
+    ConcurrentStatement, Conditionals, Designator, DiscreteRange, ElementAssociation, Expression,
+    HasUnitId, IterationScheme, LabeledConcurrentStatement, Name, ProcessStatement, Range,
+    SensitivityList, SequentialStatement, SignalAttribute, UnitId, UnitKey, Waveform, WithRef,
+};
+use crate::data::{DiagnosticHandler, ErrorCode, Symbol};
+use crate::{Config, Diagnostic, EntityId, HasTokenSpan, SrcPos, TokenAccess, TokenSpan};
+use fnv::FnvHashMap;
+use itertools::Itertools;
+use std::collections::hash_map::Entry;
+
+/// Linter that checks the contents of sensitivity lists to verify that they match
+/// the contents of the process statement.
+///
+/// This is a struct, as opposed to a function, so that units that were already analyzed don't need
+/// re-analysis.
+#[derive(Default)]
+pub(crate) struct SensitivityListLinter {
+    // library name, secondary key
+    diagnostics: FnvHashMap<(Symbol, UnitKey), Vec<Diagnostic>>,
+}
+
+impl SensitivityListLinter {
+    /// Lint all units referenced by `analyzed_units`
+    pub fn lint(
+        &mut self,
+        root: &DesignRoot,
+        config: &Config,
+        analyzed_units: &[UnitId],
+        diagnostics: &mut dyn DiagnosticHandler,
+    ) {
+        // Prune diagnostics that need to be re-computed
+        for unit in analyzed_units {
+            let key = (unit.library_name().clone(), unit.key().clone());
+            self.diagnostics.remove(&key);
+        }
+
+        // Prune diagnostics for units that no longer exist
+        self.diagnostics.retain(|(library_name, unit_key), _| {
+            if let Some(library) = root.get_lib(library_name) {
+                if library.get_unit(unit_key).is_some() {
+                    return true;
+                }
+            }
+            false
+        });
+
+        for unit in analyzed_units {
+            if let Some(library) = root.get_lib(unit.library_name()) {
+                match self
+                    .diagnostics
+                    .entry((unit.library_name().clone(), unit.key().clone()))
+                {
+                    Entry::Occupied(_) => {}
+                    Entry::Vacant(vacant_entry) => {
+                        if let Some(unit) = library.get_unit(&vacant_entry.key().1) {
+                            vacant_entry.insert(analyze_unit(root, unit));
+                        }
+                    }
+                }
+            }
+        }
+
+        for ((library_name, _), unit_diagnostics) in self.diagnostics.iter() {
+            if let Some(library_config) = config.get_library(&library_name.name_utf8()) {
+                if !library_config.is_third_party {
+                    diagnostics.append(unit_diagnostics.iter().cloned());
+                }
+            }
+        }
+    }
+}
+
+/// Analyze a single unit for issues in the sensitivity list.
+fn analyze_unit(root: &DesignRoot, unit: &LockedUnit) -> Vec<Diagnostic> {
+    let mut diagnostics = Vec::new();
+    let mut searcher = ProcessSearcher::new(|process, ctx| {
+        diagnostics.append(&mut lint_sensitivity_list(root, ctx, process))
+    });
+    let _ = unit
+        .unit
+        .expect_analyzed()
+        .search(&unit.tokens, &mut searcher);
+    diagnostics
+}
+
+/// Analyzes a process statement to check whether the sensitivity list contains any
+/// superfluous signals or any signals that are read in the process, but are not present in the
+/// sensitivity list.
+fn lint_sensitivity_list(
+    root: &DesignRoot,
+    ctx: &dyn TokenAccess,
+    process: &ProcessStatement,
+) -> Vec<Diagnostic> {
+    // Process without sensitivity list => don't lint
+    let Some(sensitivity_list) = &process.sensitivity_list else {
+        return vec![];
+    };
+    // Process with all as sensitivity list => don't link
+    let SensitivityList::Names(names) = &sensitivity_list.item else {
+        return vec![];
+    };
+    // Sequential processes => don't lint
+    // here, one could also add a lint to check for the clock and possibly reset signal.
+    if get_likely_process_category(root, process) == ProcessCategory::Sequential {
+        return vec![];
+    }
+
+    let signals_in_sensitivity_list: FnvHashMap<_, _> = names
+        .iter()
+        .flat_map(|name| {
+            name.item
+                .get_suffix_reference_disregard_index()
+                .map(|reference| (reference, name.span))
+        })
+        .collect();
+    let mut searcher = SensitivityListChecker {
+        root,
+        sensitivity_list: signals_in_sensitivity_list.clone(),
+        superfluous_entities: signals_in_sensitivity_list,
+        found_entities: FnvHashMap::default(),
+    };
+    let _ = process.statements.search(ctx, &mut searcher);
+
+    let mut missing_signals = searcher.found_entities.into_iter().collect_vec();
+    let mut diagnostics = Vec::new();
+    if !missing_signals.is_empty() {
+        missing_signals.sort_by(|(_, pos1), (_, pos2)| pos1.cmp(pos2));
+        let diagnostic_pos = process.span.start_token.get_pos(ctx);
+        let mut diag = Diagnostic::new(
+            diagnostic_pos,
+            format!(
+                "{} {} {} not read in the sensitivity list",
+                pluralize(missing_signals.len(), "The signal", "Signals"),
+                missing_signals
+                    .iter()
+                    .map(|(sig, _)| format!("'{}'", root.get_ent(*sig).designator))
+                    .join(", "),
+                pluralize(missing_signals.len(), "is", "are"),
+            ),
+            ErrorCode::MissingInSensitivityList,
+        );
+        for (signal, pos) in missing_signals {
+            diag.add_related(
+                pos,
+                format!(
+                    "signal '{}' first read here",
+                    root.get_ent(signal).designator
+                ),
+            )
+        }
+        diagnostics.push(diag);
+    }
+
+    for (_, pos) in searcher.superfluous_entities {
+        diagnostics.push(Diagnostic::new(
+            pos.get_pos(ctx),
+            "Signal is never read in the process",
+            ErrorCode::SuperfluousInSensitivityList,
+        ));
+    }
+    diagnostics
+}
+
+/// The ProcessSearcher searches for processes and calls the provided callback
+/// for each process found.
+struct ProcessSearcher<S>
+where
+    S: FnMut(&ProcessStatement, &dyn TokenAccess),
+{
+    callback: S,
+}
+
+impl<S> ProcessSearcher<S>
+where
+    S: FnMut(&ProcessStatement, &dyn TokenAccess),
+{
+    /// Constructs a new searcher with the provided callback
+    fn new(func: S) -> ProcessSearcher<S> {
+        ProcessSearcher { callback: func }
+    }
+}
+
+impl<S> Searcher for ProcessSearcher<S>
+where
+    S: FnMut(&ProcessStatement, &dyn TokenAccess),
+{
+    fn search_decl(&mut self, ctx: &dyn TokenAccess, decl: FoundDeclaration<'_>) -> SearchState {
+        // prevent searching package bodies
+        if matches!(decl.ast, DeclarationItem::PackageBody(_)) {
+            return SearchState::Finished(SearchResult::NotFound);
+        }
+        if let DeclarationItem::ConcurrentStatement(LabeledConcurrentStatement {
+            label: _,
+            statement:
+                WithTokenSpan {
+                    span: _,
+                    item: ConcurrentStatement::Process(process),
+                },
+        }) = &decl.ast
+        {
+            (self.callback)(process, ctx)
+        }
+        SearchState::NotFinished
+    }
+}
+
+fn pluralize<'a>(len: usize, singular: &'a str, plural: &'a str) -> &'a str {
+    if len > 1 {
+        plural
+    } else {
+        singular
+    }
+}
+
+struct SensitivityListChecker<'a> {
+    root: &'a DesignRoot,
+    /// The sensitivity list present at the generate statement
+    sensitivity_list: FnvHashMap<EntityId, TokenSpan>,
+    /// Additional entities that are in the Sensitivity list, but are never read in the process.
+    superfluous_entities: FnvHashMap<EntityId, TokenSpan>,
+    /// A set of named entities that form the sensitivity list.
+    /// Additionally, the token span of the first occurrence is also provided.
+    found_entities: FnvHashMap<EntityId, SrcPos>,
+}
+
+impl SensitivityListChecker<'_> {
+    fn analyze_expression(&mut self, expr: &Expression, span: TokenSpan, ctx: &dyn TokenAccess) {
+        match expr {
+            Expression::Binary(_, lhs, rhs) => {
+                self.analyze_expression(&lhs.item, lhs.span, ctx);
+                self.analyze_expression(&rhs.item, rhs.span, ctx);
+            }
+            Expression::Unary(_, expr) => {
+                self.analyze_expression(&expr.item, expr.span, ctx);
+            }
+            Expression::Aggregate(associations) => {
+                for assoc in associations {
+                    match &assoc.item {
+                        ElementAssociation::Positional(expr) => {
+                            self.analyze_expression(&expr.item, expr.span, ctx)
+                        }
+                        ElementAssociation::Named(_, expr) => {
+                            self.analyze_expression(&expr.item, expr.span, ctx)
+                        }
+                    }
+                }
+            }
+            Expression::Qualified(qual_expr) => {
+                self.analyze_expression(&qual_expr.expr.item, qual_expr.expr.span, ctx)
+            }
+            Expression::Name(name) => self.analyze_name(name, span, ctx, false),
+            Expression::New(allocator) => match &allocator.item {
+                Allocator::Qualified(qual_expr) => {
+                    self.analyze_expression(&qual_expr.expr.item, qual_expr.expr.span, ctx)
+                }
+                Allocator::Subtype(_) => {}
+            },
+            Expression::Parenthesized(expr) => self.analyze_expression(&expr.item, expr.span, ctx),
+            Expression::Literal(_) => {}
+        }
+    }
+
+    fn analyze_name(
+        &mut self,
+        name: &Name,
+        pos: TokenSpan,
+        ctx: &dyn TokenAccess,
+        remove_only: bool,
+    ) {
+        use Name::*;
+        match name {
+            Designator(designator) => {
+                self.analyze_designator(designator, pos, ctx, remove_only);
+            }
+            Selected(prefix, suffix) => {
+                self.analyze_name(&prefix.item, prefix.span, ctx, true);
+                self.analyze_designator(&suffix.item, pos, ctx, remove_only);
+            }
+            SelectedAll(prefix) => self.analyze_name(&prefix.item, prefix.span, ctx, remove_only),
+            Slice(prefix, _) => self.analyze_name(&prefix.item, prefix.span, ctx, remove_only),
+            Attribute(attr) => self.analyze_attribute_name(attr, ctx),
+            CallOrIndexed(coi) => {
+                self.analyze_name(&coi.name.item, coi.name.span, ctx, remove_only);
+                for item in &coi.parameters.items {
+                    match &item.actual.item {
+                        ActualPart::Expression(expr) => {
+                            self.analyze_expression(expr, item.actual.span, ctx)
+                        }
+                        ActualPart::Open => {}
+                    }
+                }
+            }
+            External(_) => {
+                // TODO: External names aren't analyzed atm
+            }
+        }
+    }
+
+    fn analyze_designator(
+        &mut self,
+        designator: &WithRef<Designator>,
+        pos: TokenSpan,
+        ctx: &dyn TokenAccess,
+        remove_only: bool,
+    ) {
+        if let Some(reference) = designator.reference.get() {
+            self.superfluous_entities.remove(&reference);
+            let ent = self.root.get_ent(reference);
+            if ent.is_signal() && !self.sensitivity_list.contains_key(&reference) && !remove_only {
+                self.found_entities
+                    .entry(reference)
+                    .or_insert_with(|| pos.get_pos(ctx));
+            }
+        }
+    }
+
+    fn analyze_attribute_name(&mut self, attr: &AttributeName, ctx: &dyn TokenAccess) {
+        use AttributeDesignator::*;
+        match attr.attr.item {
+            Ident(_) | Image => {}
+            // These likely do not belong inside a sensitivity list.
+            // However, true analysis can only be performed when it is known whether the name is
+            // static or not.
+            _ => return,
+        }
+        self.analyze_name(&attr.name.item, attr.name.span, ctx, false);
+        if let Some(expr) = &attr.expr {
+            self.analyze_expression(&expr.item, expr.span, ctx)
+        }
+    }
+
+    fn analyze_waveform(&mut self, waveform: &Waveform, ctx: &dyn TokenAccess) {
+        match waveform {
+            Waveform::Elements(elements) => {
+                for element in elements {
+                    self.analyze_expression(&element.value.item, element.value.span, ctx);
+                }
+            }
+            Waveform::Unaffected(_) => {}
+        }
+    }
+
+    fn analyze_signal_rhs(&mut self, rhs: &AssignmentRightHand<Waveform>, ctx: &dyn TokenAccess) {
+        match rhs {
+            AssignmentRightHand::Simple(waveform) => self.analyze_waveform(waveform, ctx),
+            AssignmentRightHand::Conditional(conditionals) => {
+                for conditional in &conditionals.conditionals {
+                    self.analyze_waveform(&conditional.item, ctx);
+                    self.analyze_expression(
+                        &conditional.condition.item,
+                        conditional.condition.span,
+                        ctx,
+                    );
+                }
+                if let Some((else_item, _)) = &conditionals.else_item {
+                    self.analyze_waveform(else_item, ctx);
+                }
+            }
+            AssignmentRightHand::Selected(selection) => {
+                self.analyze_expression(&selection.expression.item, selection.expression.span, ctx);
+                for alternative in &selection.alternatives {
+                    self.analyze_waveform(&alternative.item, ctx)
+                }
+            }
+        }
+    }
+
+    fn analyze_conditionals(
+        &mut self,
+        conditionals: &Conditionals<WithTokenSpan<Expression>>,
+        ctx: &dyn TokenAccess,
+    ) {
+        for conditional in &conditionals.conditionals {
+            self.analyze_expression(&conditional.item.item, conditional.item.span, ctx);
+            self.analyze_expression(&conditional.condition.item, conditional.condition.span, ctx);
+        }
+        if let Some((else_item, _)) = &conditionals.else_item {
+            self.analyze_expression(&else_item.item, else_item.span, ctx);
+        }
+    }
+
+    fn analyze_discrete_range(&mut self, range: &DiscreteRange, ctx: &dyn TokenAccess) {
+        match range {
+            DiscreteRange::Discrete(name, range) => {
+                self.analyze_name(&name.item, name.span, ctx, false);
+                if let Some(range) = range {
+                    self.analyze_range(range, ctx)
+                }
+            }
+            DiscreteRange::Range(range) => self.analyze_range(range, ctx),
+        }
+    }
+
+    fn analyze_range(&mut self, range: &Range, ctx: &dyn TokenAccess) {
+        match range {
+            Range::Range(range_constraint) => {
+                self.analyze_expression(
+                    &range_constraint.left_expr.item,
+                    range_constraint.left_expr.span,
+                    ctx,
+                );
+                self.analyze_expression(
+                    &range_constraint.right_expr.item,
+                    range_constraint.right_expr.span,
+                    ctx,
+                );
+            }
+            Range::Attribute(attribute) => self.analyze_attribute_name(attribute, ctx),
+        }
+    }
+
+    fn analyze_variable_rhs(
+        &mut self,
+        rhs: &AssignmentRightHand<WithTokenSpan<Expression>>,
+        ctx: &dyn TokenAccess,
+    ) {
+        match rhs {
+            AssignmentRightHand::Simple(waveform) => {
+                self.analyze_expression(&waveform.item, waveform.span, ctx)
+            }
+            AssignmentRightHand::Conditional(conditionals) => {
+                self.analyze_conditionals(conditionals, ctx)
+            }
+            AssignmentRightHand::Selected(selection) => {
+                self.analyze_expression(&selection.expression.item, selection.expression.span, ctx);
+                for alternative in &selection.alternatives {
+                    self.analyze_expression(&alternative.item.item, alternative.item.span, ctx)
+                }
+            }
+        }
+    }
+}
+
+impl Searcher for SensitivityListChecker<'_> {
+    fn search_decl(&mut self, ctx: &dyn TokenAccess, decl: FoundDeclaration<'_>) -> SearchState {
+        use SequentialStatement::*;
+        if let DeclarationItem::SequentialStatement(stmt) = &decl.ast {
+            match &stmt.statement.item {
+                Assert(stmt) => {
+                    self.analyze_expression(&stmt.condition.item, stmt.condition.span, ctx)
+                }
+                Report(stmt) => self.analyze_expression(&stmt.report.item, stmt.report.span, ctx),
+                Next(stmt) => {
+                    if let Some(expr) = &stmt.condition {
+                        self.analyze_expression(&expr.item, expr.span, ctx)
+                    }
+                }
+                Exit(stmt) => {
+                    if let Some(expr) = &stmt.condition {
+                        self.analyze_expression(&expr.item, expr.span, ctx)
+                    }
+                }
+                Return(stmt) => {
+                    if let Some(expr) = &stmt.expression {
+                        self.analyze_expression(&expr.item, expr.span, ctx)
+                    }
+                }
+                VariableAssignment(stmt) => self.analyze_variable_rhs(&stmt.rhs, ctx),
+                SignalAssignment(stmt) => self.analyze_signal_rhs(&stmt.rhs, ctx),
+                SignalForceAssignment(stmt) => self.analyze_variable_rhs(&stmt.rhs, ctx),
+                If(if_stmt) => {
+                    // Conditional statements are automatically visited
+                    for conditional in &if_stmt.conds.conditionals {
+                        self.analyze_expression(
+                            &conditional.condition.item,
+                            conditional.condition.span,
+                            ctx,
+                        );
+                    }
+                }
+                Case(case_stmt) => {
+                    self.analyze_expression(
+                        &case_stmt.expression.item,
+                        case_stmt.expression.span,
+                        ctx,
+                    );
+                }
+                Loop(loop_stmt) => match &loop_stmt.iteration_scheme {
+                    Some(IterationScheme::For(_, range)) => self.analyze_discrete_range(range, ctx),
+                    Some(IterationScheme::While(expr)) => {
+                        self.analyze_expression(&expr.item, expr.span, ctx)
+                    }
+                    None => {}
+                },
+                ProcedureCall(call_or_indexed) => {
+                    for item in &call_or_indexed.item.parameters.items {
+                        match &item.actual.item {
+                            ActualPart::Expression(expr) => {
+                                self.analyze_expression(expr, call_or_indexed.span, ctx)
+                            }
+                            ActualPart::Open => {}
+                        }
+                    }
+                }
+                Wait(_) | Null | SignalReleaseAssignment(_) => {}
+            }
+        }
+        SearchState::NotFinished
+    }
+}
+
+#[derive(Eq, PartialEq, Copy, Clone, Debug)]
+enum ProcessCategory {
+    Combinational,
+    Sequential,
+}
+
+/// Returns the likely process category (i.e., either sequential or combinational).
+/// This is only a heuristic that works correctly for common cases like
+/// ```vhdl
+/// process x(clk) is
+/// begin
+///     if rising_edge(clk) then
+///         -- ...
+///     end if;
+/// end process;
+/// ```
+/// but won't work for some more exotic, mixed processes.
+fn get_likely_process_category(root: &DesignRoot, process: &ProcessStatement) -> ProcessCategory {
+    // using iter().any(...) guards against edge cases like the following:
+    // ```
+    // process(clkA, clkB) is
+    // begin
+    //     if rising_edge(clkA) then
+    //     end if;
+    //
+    //     some_assignment <= xy;
+    // end process;
+    let is_likely_clocked = process.statements.iter().any(|stmt| {
+        match &stmt.statement.item {
+            SequentialStatement::If(if_stmt) => {
+                // this is always guaranteed to be present
+                let first_conditional = &if_stmt.conds.conditionals[0];
+                is_likely_clocked(root, &first_conditional.condition.item)
+                    || (if_stmt.conds.conditionals.len() == 2
+                        && is_likely_clocked(root, &if_stmt.conds.conditionals[1].condition.item))
+            }
+            _ => false,
+        }
+    });
+    if is_likely_clocked {
+        ProcessCategory::Sequential
+    } else {
+        ProcessCategory::Combinational
+    }
+}
+
+/// Returns whether an expression is likely clocked depending on whether that expression contains
+/// the `'event` attribute or the `rising_edge(...)` resp. `falling_edge(...)` signals.
+/// This is a heuristic and does not trigger for special cases such as `true or rising_edge(clk)`
+/// which would not be clocked.
+fn is_likely_clocked(root: &DesignRoot, expression: &Expression) -> bool {
+    match expression {
+        Expression::Binary(_, lhs, rhs) => {
+            is_likely_clocked(root, &lhs.item) || is_likely_clocked(root, &rhs.item)
+        }
+        Expression::Unary(_, expr) => is_likely_clocked(root, &expr.item),
+        Expression::Aggregate(_) => false,
+        Expression::Qualified(_) => false,
+        Expression::Name(name) => match name.as_ref() {
+            Name::Attribute(attribute) => {
+                attribute.attr.item == AttributeDesignator::Signal(SignalAttribute::Event)
+            }
+            Name::CallOrIndexed(coi) => {
+                if let Some(reference) = coi.name.item.get_suffix_reference() {
+                    let ent = root.get_ent(reference);
+                    if let Some(library_name) = ent.library_name() {
+                        if (library_name.name_utf8().to_lowercase() == "ieee"
+                            || library_name.name_utf8().to_lowercase() == "std")
+                            && (ent.designator.to_string().to_lowercase() == "rising_edge"
+                                || ent.designator.to_string().to_lowercase() == "falling_edge")
+                        {
+                            return true;
+                        }
+                    }
+                }
+                false
+            }
+            _ => false,
+        },
+        Expression::Literal(_) => false,
+        Expression::New(_) => false,
+        Expression::Parenthesized(expr) => is_likely_clocked(root, &expr.item),
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use crate::analysis::tests::{check_no_diagnostics, LibraryBuilder};
+    use crate::syntax::test::check_diagnostics;
+    use std::cell::Cell;
+
+    #[test]
+    fn extract_sensitivity_list_no_items() {
+        let mut builder = LibraryBuilder::new();
+
+        builder.code(
+            "libname",
+            "
+entity ent is
+end entity;
+
+architecture a of ent is
+begin
+   process is
+   begin
+   end process;
+end architecture;",
+        );
+
+        let (root, diagnostics) = builder.get_analyzed_root();
+        check_no_diagnostics(&diagnostics);
+
+        let num_of_searches = Cell::new(0);
+        let mut searcher = ProcessSearcher::new(|proc, ctx| {
+            num_of_searches.set(num_of_searches.get() + 1);
+            let diag = lint_sensitivity_list(&root, ctx, proc);
+            assert_eq!(diag, Vec::default());
+        });
+        let _ = root.search(&mut searcher);
+        assert_eq!(num_of_searches.get(), 1)
+    }
+
+    #[test]
+    fn extract_sensitivity_list_multiple_items() {
+        let mut builder = LibraryBuilder::new();
+
+        let code = builder.code(
+            "libname",
+            "
+entity ent is
+    port (
+        sig_a : in bit
+   );
+end entity;
+
+architecture a of ent is
+    -- all of these signals should be considered as being 'read' in the sensitivity list
+    signal sig_b, sig_c, sig_d, sig_e, sig_f, sig_g, sig_h, sig_i, sig_j, sig_k, sig_l : bit;
+    -- all of these signals are fine and present in the sensitivity list
+    signal sig_0 : bit;
+    signal sig_1 : bit_vector(1 downto 0);
+    type t_rec is record
+        foo : bit;
+    end record;
+    signal sig_2 : t_rec;
+    signal sig_3, sig_4, sig_5, sig_6 : bit; 
+    -- superfluous signal
+    signal additional : bit;
+    signal void : bit;
+
+    function func(din: bit) return bit is
+    begin
+    end func;
+begin
+   process(sig_0, sig_1(0), sig_2, sig_3, sig_4, sig_5, sig_6, additional) is
+       variable void_var : bit;
+   begin
+       if (sig_5 = '1') then
+           void <= sig_6;
+       end if;
+       void <= sig_3 or sig_4;
+       void <= sig_3;
+       void <= sig_2.foo;
+       void <= sig_0;
+       void <= sig_1(0);
+       void <= sig_a;
+       void <= sig_b;
+       void <= sig_b;
+       void_var := sig_c;
+       assert sig_d = '1';
+       report bit'image(sig_e);
+       void <= force sig_f;
+       if sig_g = '1' then
+           void <= sig_h;
+       end if;
+       case sig_i is
+           when '0' => void <= sig_j;
+           when others => void <= sig_k;
+       end case;
+       void <= func(sig_l);
+   end process;
+end architecture;",
+        );
+
+        let (root, diagnostics) = builder.get_analyzed_root();
+        check_no_diagnostics(&diagnostics);
+
+        let expected_signals = [
+            "sig_a", "sig_b", "sig_c", "sig_d", "sig_e", "sig_f", "sig_g", "sig_h", "sig_i",
+            "sig_j", "sig_k", "sig_l",
+        ]
+        .map(|value| (value, code.s(value, 2).pos()));
+
+        let num_of_searches = Cell::new(0);
+        let mut searcher = ProcessSearcher::new(|proc, ctx| {
+            num_of_searches.set(num_of_searches.get() + 1);
+            let res = lint_sensitivity_list(&root, ctx, proc);
+            let mut expected_missing_diag = Diagnostic::new(
+                code.s1("process").pos(), 
+                "Signals 'sig_a', 'sig_b', 'sig_c', 'sig_d', 'sig_e', 'sig_f', 'sig_g', 'sig_h', 'sig_i', 'sig_j', 'sig_k', 'sig_l' are not read in the sensitivity list",
+                ErrorCode::MissingInSensitivityList
+            );
+            for (name, pos) in &expected_signals {
+                expected_missing_diag.add_related(pos, format!("signal '{name}' first read here"));
+            }
+            let expected_superfluous_diag = Diagnostic::new(
+                code.s("additional", 2),
+                "Signal is never read in the process",
+                ErrorCode::SuperfluousInSensitivityList,
+            );
+            check_diagnostics(res, vec![expected_missing_diag, expected_superfluous_diag]);
+        });
+        let _ = root.search(&mut searcher);
+        assert_eq!(num_of_searches.get(), 1)
+    }
+
+    #[test]
+    fn check_likely_process_category() {
+        let mut builder = LibraryBuilder::new();
+        builder.add_std_logic_1164();
+
+        builder.code(
+            "libname",
+            "
+library ieee;
+use ieee.std_logic_1164.all;
+
+entity ent is
+end entity;
+
+architecture a of ent is
+    signal sig_a, sig_b : bit;
+    signal sig_c_sl : std_logic;
+begin
+    a_comb : process(sig_a) is
+    begin
+        if sig_a = '1' then
+        end if;
+    end process a_comb;
+
+    b_comb : process(sig_a) is
+    begin
+        sig_a <= '1';
+    end process b_comb;
+
+    c_comb : process(sig_a, sig_b) is
+    begin
+        sig_a <= '1';
+        sig_b <= '0';
+    end process c_comb;
+
+    a_seq : process(sig_a) is
+    begin
+        if rising_edge(sig_a) then
+        end if;
+    end process a_seq;
+
+    b_seq : process(sig_a, sig_b) is
+    begin
+        if sig_a = '1' then
+        elsif rising_edge(sig_b) then
+        end if;
+    end process b_seq;
+
+    c_seq : process(sig_a) is
+    begin
+        if sig_a'event and sig_a = '1' then
+        end if;
+    end process c_seq;
+
+    d_seq : process(sig_a, sig_b) is
+    begin
+        if sig_b = '1' then
+        elsif sig_a'event and sig_a = '0' then
+        end if;
+    end process d_seq;
+
+    e_seq : process(sig_a) is
+    begin
+        if rising_edge(sig_a) then
+        end if;
+    end process e_seq;
+
+    f_seq : process(sig_a, sig_b) is
+    begin
+        if rising_edge(sig_a) then
+        end if;
+
+        if rising_edge(sig_b) then
+        end if;
+    end process f_seq;
+end architecture;",
+        );
+
+        let (root, diagnostics) = builder.get_analyzed_root();
+        check_no_diagnostics(&diagnostics);
+
+        let idx = Cell::new(0);
+        let mut searcher = ProcessSearcher::new(|proc, _| {
+            if idx.get() <= 2 {
+                assert_eq!(
+                    get_likely_process_category(&root, proc),
+                    ProcessCategory::Combinational,
+                    "Process #{} is sequential but should be combinatorial",
+                    idx.get()
+                );
+            } else {
+                assert_eq!(
+                    get_likely_process_category(&root, proc),
+                    ProcessCategory::Sequential,
+                    "Process #{} is combinatorial but should be sequential",
+                    idx.get()
+                );
+            }
+            idx.set(idx.get() + 1);
+        });
+        let _ = root.search(&mut searcher);
+        assert_eq!(idx.get(), 9);
+    }
+}
diff --git a/vhdl_lang/src/main.rs b/vhdl_lang/src/main.rs
index 0622877d..b242773f 100644
--- a/vhdl_lang/src/main.rs
+++ b/vhdl_lang/src/main.rs
@@ -122,7 +122,7 @@ fn parse_and_analyze_project(
 
     let severity_map = *config.severities();
     let mut project = Project::from_config(config, &mut msg_printer);
-    project.enable_unused_declaration_detection();
+    project.enable_all_linters();
     let diagnostics = project.analyse();
 
     show_diagnostics(&diagnostics, &severity_map);
diff --git a/vhdl_lang/src/named_entity.rs b/vhdl_lang/src/named_entity.rs
index 7384288c..a46c767b 100644
--- a/vhdl_lang/src/named_entity.rs
+++ b/vhdl_lang/src/named_entity.rs
@@ -417,6 +417,10 @@ impl<'a> AnyEnt<'a> {
         )
     }
 
+    pub fn is_signal(&self) -> bool {
+        matches!(self.kind(), AnyEntKind::Object(obj) if obj.is_signal())
+    }
+
     pub fn is_declared_by(&self, other: EntRef<'_>) -> bool {
         if let Related::DeclaredBy(ent) = self.related {
             if ent.id() == other.id() {
diff --git a/vhdl_lang/src/named_entity/object.rs b/vhdl_lang/src/named_entity/object.rs
index f049c4d6..cb2d552b 100644
--- a/vhdl_lang/src/named_entity/object.rs
+++ b/vhdl_lang/src/named_entity/object.rs
@@ -152,6 +152,10 @@ impl<'a> Object<'a> {
         self
     }
 
+    pub fn is_signal(&self) -> bool {
+        self.class == ObjectClass::Signal
+    }
+
     pub fn is_port(&self) -> bool {
         matches!(self.iface, Some(ObjectInterface::Port(..)))
     }
diff --git a/vhdl_lang/src/project.rs b/vhdl_lang/src/project.rs
index 0d946201..3a781149 100644
--- a/vhdl_lang/src/project.rs
+++ b/vhdl_lang/src/project.rs
@@ -10,6 +10,7 @@ use crate::ast::DesignFile;
 use crate::completion::{list_completion_options, CompletionItem};
 use crate::config::Config;
 use crate::lint::dead_code::UnusedDeclarationsLinter;
+use crate::lint::sensitivity_list::SensitivityListLinter;
 use crate::named_entity::EntRef;
 use crate::standard::VHDLStandard;
 use crate::syntax::VHDLParser;
@@ -19,13 +20,19 @@ use std::collections::hash_map::Entry;
 use std::path::Path;
 use vhdl_lang::Token;
 
+#[derive(Default)]
+struct Linters {
+    unused_declarations: Option<UnusedDeclarationsLinter>,
+    sensitivity_list: Option<SensitivityListLinter>,
+}
+
 pub struct Project {
     parser: VHDLParser,
     config: Config,
     root: DesignRoot,
     files: FnvHashMap<FilePath, SourceFile>,
     empty_libraries: FnvHashSet<Symbol>,
-    lint: Option<UnusedDeclarationsLinter>,
+    lint: Linters,
 }
 
 impl Project {
@@ -36,13 +43,22 @@ impl Project {
             files: FnvHashMap::default(),
             empty_libraries: FnvHashSet::default(),
             parser,
-            lint: None,
+            lint: Linters::default(),
             config: Config::default(),
         }
     }
 
     pub fn enable_unused_declaration_detection(&mut self) {
-        self.lint = Some(UnusedDeclarationsLinter::default());
+        self.lint.unused_declarations = Some(UnusedDeclarationsLinter::default());
+    }
+
+    pub fn enable_sensitivity_list_linting(&mut self) {
+        self.lint.sensitivity_list = Some(SensitivityListLinter::default());
+    }
+
+    pub fn enable_all_linters(&mut self) {
+        self.enable_unused_declaration_detection();
+        self.enable_sensitivity_list_linting();
     }
 
     /// Create instance from given configuration.
@@ -238,7 +254,11 @@ impl Project {
 
         let analyzed_units = self.root.analyze(&mut diagnostics);
 
-        if let Some(ref mut lint) = self.lint {
+        if let Some(ref mut lint) = self.lint.unused_declarations {
+            lint.lint(&self.root, &self.config, &analyzed_units, &mut diagnostics);
+        }
+
+        if let Some(ref mut lint) = self.lint.sensitivity_list {
             lint.lint(&self.root, &self.config, &analyzed_units, &mut diagnostics);
         }
 
diff --git a/vhdl_lang/tests/integration_tests.rs b/vhdl_lang/tests/integration_tests.rs
index f3625b8f..8c244bb6 100644
--- a/vhdl_lang/tests/integration_tests.rs
+++ b/vhdl_lang/tests/integration_tests.rs
@@ -29,7 +29,7 @@ pub fn parses_example_project_without_errors() {
 
     let severity_map = *config.severities();
     let mut project = Project::from_config(config, &mut msg_printer);
-    project.enable_unused_declaration_detection();
+    project.enable_all_linters();
 
     let diagnostics = project.analyse();
     let diagnostics_with_errors = diagnostics
diff --git a/vhdl_ls/src/vhdl_server/lifecycle.rs b/vhdl_ls/src/vhdl_server/lifecycle.rs
index f61d2b76..ff0b0ce6 100644
--- a/vhdl_ls/src/vhdl_server/lifecycle.rs
+++ b/vhdl_ls/src/vhdl_server/lifecycle.rs
@@ -50,7 +50,7 @@ impl VHDLServer {
         let config = self.load_config();
         self.severity_map = *config.severities();
         self.project = Project::from_config(config, &mut self.message_filter());
-        self.project.enable_unused_declaration_detection();
+        self.project.enable_all_linters();
         if let Some(options) = &init_params.initialization_options {
             self.apply_initial_options(options)
         }