blob: d647cabe2246f2d698fbbbdc037181e39aeefeb7 [file]
.. _ci_test_plan:
CI Test Plan Selector (test_plan_v2)
#####################################
The CI test plan selector is a modular Python script located at
``scripts/ci/test_plan_v2.py``. Its purpose is to analyse the set of files
changed in a Pull Request and emit a targeted twister test plan exercising
only the tests that are plausibly affected by the change while avoiding a
full tree-wide run on every commit.
The script writes two artefacts:
* ``testplan.json`` passed to twister via ``twister --load-tests``.
* ``.testplan`` a plain ``KEY=value`` environment file consumed by CI
orchestration scripts, containing ``TWISTER_TESTS``, ``TWISTER_NODES``, and
``TWISTER_FULL``.
Architecture
************
The selector is built around a *strategy pipeline*. Each strategy is an
independent analyser that:
1. Receives the list of changed files (or the subset not yet consumed by
earlier strategies).
2. Inspects those files according to its own logic.
3. Returns a list of :class:`TwisterCall` descriptors and the set of files it
has *handled*.
The :class:`Orchestrator` drives the pipeline, merges all results into a
:class:`PlanAccumulator`, deduplicates test suites, and writes the output
files.
Strategy ordering
*****************
Strategies run in a fixed order that reflects two principles:
**Specificity over generality.**
The most precise strategies run first. A file that lives inside
``tests/kernel/sched/`` is handled definitively by :class:`DirectTestStrategy`
(run exactly those tests) before the catch-all :class:`MaintainerAreaStrategy`
can add an entire Kernel area sweep.
**Consume-before-additive.**
Consuming strategies run before additive ones. Once a consuming strategy
claims a file, downstream strategies never see it. This prevents a change
to a board file from also triggering a driver compat scan and a Kconfig sweep
for the same path.
The current order is:
.. list-table::
:header-rows: 1
:widths: 5 20 10 65
* - #
- Strategy
- Consumes
- Rationale
* - 0
- :class:`ComplexityStrategy`
- No
- Scores the patchset using pydriller and lizard. Must run first so the
score is available to :class:`RiskClassifierStrategy`. Emits no twister
calls.
* - 1
- :class:`IgnoreStrategy`
- Yes
- Silently drops files that can never affect tests (documentation, CI
workflows, tooling). Runs early so later strategies never waste effort
on ignored paths.
* - 1b
- :class:`BoilerplateFilter`
- Yes
- Consumes files whose entire diff consists of whitespace adjustments,
blank-line changes, SPDX licence identifiers, copyright notices, or
bare comment delimiters. Such changes cannot affect runtime behaviour,
so removing them early keeps the pool clean for substantive strategies.
Requires a git commit range; no-op otherwise.
* - 2
- :class:`DirectTestStrategy`
- Yes
- A changed test or sample file must trigger *only* that test, not an
entire area-wide sweep. Consuming this file prevents
:class:`MaintainerAreaStrategy` from also adding every test in the
Kernel area when ``tests/kernel/sched/main.c`` is modified.
* - 3
- :class:`SnippetStrategy`
- Yes
- Snippet changes are self-contained: only tests that declare the snippet
in ``required_snippets`` need to run. Consuming prevents downstream
strategies from treating a snippet YAML as an unknown configuration
file.
* - 4
- :class:`BoardStrategy`
- Yes
- Board-specific changes require a targeted integration test on every
board variant. Consuming prevents a board ``.yaml`` file from also
matching Kconfig and Header strategies.
* - 5
- :class:`SoCStrategy`
- Yes
- SoC-level changes affect every board built on that SoC family.
Consuming prevents the same ``soc/`` path from reaching the Kconfig
sweep.
* - 6
- :class:`ManifestStrategy`
- Yes
- A changed ``west.yml`` requires module-tagged integration tests, not a
generic maintainer-area run. Consuming the manifest file prevents the
catch-all from adding unrelated tests.
* - 7
- :class:`DriverCompatStrategy`
- No
- Additive: driver files may also be covered by area patterns, so the
file is not consumed and :class:`KconfigImpactStrategy` and
:class:`MaintainerAreaStrategy` can add further coverage.
* - 8
- :class:`DtsBindingStrategy`
- No
- Additive: binding changes combine overlay-based test selection with
board-targeted area calls.
* - 9
- :class:`KconfigImpactStrategy`
- No
- Additive: Kconfig changes may affect many unrelated files; only consumes
when a non-widespread symbol is found, leaving widespread-symbol files
for the catch-all.
* - 10
- :class:`HeaderImpactStrategy`
- No
- Additive: header changes trace include users back to maintainer areas.
Widespread headers (included in more than the configured threshold) are
skipped.
* - 11
- :class:`MaintainerAreaStrategy`
- No
- Catch-all: matches any remaining file against ``MAINTAINERS.yml`` area
patterns and emits ``--test-pattern`` calls for each matching area that
has a non-empty ``tests:`` list.
Boilerplate filter
******************
:class:`BoilerplateFilter` runs immediately after :class:`IgnoreStrategy` and
before any test-selection strategy. It inspects the actual diff of each
changed file (via ``git diff``) and consumes those whose entire content change
is non-substantive:
* **Whitespace and blank-line changes** detected by ``git diff -w
--ignore-blank-lines``: if that command produces no output for a file,
every changed line is whitespace or blank.
* **SPDX and copyright header edits** lines that begin with
``SPDX-License-Identifier:``, ``SPDX-FileCopyrightText:``, or the word
``Copyright``.
* **Comment-delimiter-only lines** lines whose non-whitespace content
consists entirely of ``/*``, ``*/``, ``//``, ``#``, or ``*`` characters
(e.g. reformatted block-comment borders).
A file is consumed only when **every** added or removed line in its diff falls
into one of those categories. A single substantive line (a changed statement,
macro, or declaration) causes the file to pass through to downstream
strategies unchanged.
The filter is a no-op when ``--commits`` is not supplied (e.g. when using
``--modified-files``), because the diff cannot be computed without a commit
range. In that case all files remain in the pool.
Consume vs. additive behaviour
*******************************
Every strategy subclass carries a class attribute ``consumes: bool``.
When ``consumes = True``:
Files returned in the *handled* set are removed from the ``remaining`` pool
before the next strategy runs. Use this when the strategy is *authoritative*
for its file type i.e. when seeing the file in a later strategy would
produce redundant or incorrect results.
When ``consumes = False`` (default):
Files remain in the pool regardless of what the strategy returns. All
downstream strategies receive the same file list. Use this for additive
strategies that contribute *additional* test coverage without claiming
exclusive ownership.
.. note::
The :class:`Orchestrator` skips a strategy entirely when the ``remaining``
pool is empty. Because non-consuming strategies see all files, "remaining"
only shrinks through consuming strategies. As soon as the pool is empty
the orchestrator stops calling strategies.
Full-run and fallback conditions
**********************************
The orchestrator signals ``TWISTER_FULL=True`` in ``.testplan`` when either of
the following is true:
1. **Unresolved files.** After all strategies have run, at least one changed
file was not *handled* by any strategy. Rather than silently skipping
coverage for an unknown path, the script falls back to requesting a full
run so no regression slips through.
2. **Explicit full-run signal.** A strategy returns a :class:`TwisterCall`
with ``full_run=True``. When the orchestrator encounters such a call, it
immediately sets ``TWISTER_FULL=True``, empties the remaining pool, and
stops executing further calls. This mechanism allows a strategy to opt out
of targeted selection (e.g. when a core subsystem header used by the entire
tree is modified).
When ``TWISTER_FULL=True`` the CI script is expected to discard
``testplan.json`` and run twister without ``--load-tests``.
Node count calculation
**********************
``TWISTER_NODES`` in ``.testplan`` is computed as follows:
* ``0`` no tests were selected.
* ``1`` fewer than ``--tests-per-builder`` tests were selected (fits in one
builder).
* ``ceil(total / tests_per_builder)`` otherwise. The ceiling ensures that
no builder is over-capacity when the division is not exact.
The default value of ``--tests-per-builder`` is ``900``.
Adding a new strategy
*********************
1. **Subclass** :class:`SelectionStrategy` and implement the two abstract
members:
.. code-block:: python
class MyStrategy(SelectionStrategy):
consumes: bool = False # or True if authoritative
@property
def name(self):
return "MyStrategy"
def analyze(self, changed_files):
# inspect changed_files
calls = [...] # list of TwisterCall
handled = {...} # subset of changed_files this strategy owns
return calls, handled
2. **Decide consume vs. additive.** Ask: "If a downstream strategy also sees
this file, will it produce useful additional coverage, or redundant/wrong
results?" If redundant/wrong: ``consumes = True``.
3. **Insert in the correct position** in :func:`build_strategies`. As a rule
of thumb:
* Consuming strategies belong before all additive strategies.
* More-specific strategies belong before less-specific ones.
* Strategies with no side-effects on the file pool (additive) can be
ordered by cost cheapest first.
4. **Guard optional dependencies** with a late import inside ``analyze`` and
return ``[], set()`` gracefully when the dependency is missing. Use the
``# noqa: PLC0415`` comment to silence the late-import warning.
5. **Write unit tests** in ``scripts/tests/ci/test_test_plan_v2.py`` covering:
* The happy path (files correctly identified and routed).
* The no-op path (irrelevant files produce no calls and no handled set).
* Edge cases (missing files on disk, malformed YAML, empty inputs).
Shared pipeline context
***********************
:class:`PipelineContext` is a dataclass threaded through all strategies via
:func:`build_strategies`. It currently carries:
* ``complexity_score`` aggregate patchset score written by
:class:`ComplexityStrategy` and read by :class:`RiskClassifierStrategy`.
* ``file_metrics`` per-file :class:`ComplexityMetrics` mappings for
detailed logging and downstream decision-making.
New cross-strategy state should be added as typed fields on
:class:`PipelineContext` rather than as strategy-level instance variables.
Test strategy for validating selection behaviour
*************************************************
The test suite lives in ``scripts/tests/ci/test_test_plan_v2.py`` and is run
with pytest:
.. code-block:: bash
pytest scripts/tests/ci/test_test_plan_v2.py -v
Tests are organised into one class per strategy or shared component. The
following categories of tests are expected for each new strategy:
**Unit tests (no Zephyr tree required)**
Use a ``tmp_path`` fixture to create the minimum filesystem structures
(``board.yml``, ``snippet.yml``, ``testcase.yaml``, etc.) needed to exercise
each code path. These tests should be fast and hermetic.
**Helper method tests**
Test internal methods (path-walking, YAML parsing, regex extraction) in
isolation. Pass synthetic content rather than relying on real files in the
repository.
**Orchestrator integration tests**
Use mock strategies and a mock :class:`TwisterExecutor` to test the
orchestrator's consume-vs-additive logic, full-run signalling, and
``.testplan`` output without invoking twister.
**Real-tree integration tests** (optional, ``@pytest.mark.integration``)
May be added and marked with ``@pytest.mark.integration`` to be excluded
from the default run. These are only meaningful on a complete Zephyr
checkout.
Minimal test checklist for a new strategy
==========================================
* The strategy returns no calls and no handled files when no relevant files
are in the input list.
* The strategy correctly identifies and returns the expected :class:`TwisterCall`
for a known-good input.
* The strategy does not crash when required files are absent from the filesystem.
* For consuming strategies: the handled set equals the matched files and
nothing else.
* For additive strategies: the handled set is empty or equals only the files
the strategy is authoritative for.
CLI reference
*************
.. code-block:: text
usage: test_plan_v2.py [-c A..B] [-m FILE] [-f PATH]
[-o FILE] [-p PLATFORM] [--maintainers-file FILE]
[-T DIR] [--quarantine-list FILE]
[--tests-per-builder N] [--disable-strategy NAME]
[--detailed-test-id]
-c A..B Git commit range (e.g. ``main..HEAD``). Changed files
are derived from ``git diff --name-only A..B``.
-m FILE JSON file containing a list of changed file paths.
-f PATH Treat PATH as a changed file (repeatable).
-o FILE Output JSON file (default: ``testplan.json``).
-p PLATFORM Restrict all selections to this platform (repeatable).
--maintainers-file Path to ``MAINTAINERS.yml``.
-T DIR Extra testsuite root forwarded to every twister call.
--quarantine-list Quarantine YAML forwarded to twister.
--tests-per-builder Tests per CI builder node (default: 900).
--disable-strategy Skip a strategy by name (repeatable).
--detailed-test-id Pass ``--detailed-test-id`` to twister.