Repository: nipreps/nibabies

Issue:
T1 and T2 preproc anatomical derivatives are skull stripped for mcribs and infantfs reconstruction methods
### What happened?

Particularly when using mcribs and infantfs for surface reconstruction, the preproc T1w and T2w anatomical derivatives are skull stripped. While when using adult freesurfer for surface reconstruction these same derivatives are not skull stripped. It is my understanding that none of the derivatives should be skull stripped regardless of surface reconstruction method.

Below is an example run command and the directory structure of the external derivatives that were passed in:

### What command did you use?

```shell
singularity run --cleanenv \
	-B /tmp/bids_dir/sub-100619_ses-17mo:/bids_dir \
	-B /tmp/processed/nibabies/sub-100619_ses-17mo:/output_dir \
	-B /tmp/work_dir/nibabies/sub-100619_ses-17mo:/wd \
	-B /tmp/processed/bibsnet/sub-100619_ses-17mo:/derivatives \
	-B /tmp/license.txt:/license.txt \
	 /home/faird/shared/code/external/pipelines/nibabies/nibabies_24.0.0.sif \
	 /bids_dir /output_dir participant \
	--output-spaces MNI152NLin6Asym:res-2 \
	--fs-license-file /license.txt \
	--omp-nthreads 8 \
	--nprocs 8 \
	--cifti-output 91k \
	-vv \
	--project-goodvoxels \
	--fd-radius 45 \
	--derivatives /derivatives \
	--surface-recon-method infantfs \
	--age-months 17mo \
	-w /wd


sub-100619/
    ses-17mo/
        anat/
             sub-100619_ses-17mo_space-T1w_desc-aseg_dseg.json
             sub-100619_ses-17mo_space-T1w_desc-aseg_dseg.nii.gz
             sub-100619_ses-17mo_space-T1w_desc-aseg_volumes.json
             sub-100619_ses-17mo_space-T1w_desc-aseg_volumes.tsv
             sub-100619_ses-17mo_space-T1w_desc-brain_mask.json
             sub-100619_ses-17mo_space-T1w_desc-brain_mask.nii.gz
             sub-100619_ses-17mo_space-T2w_desc-aseg_dseg.json
             sub-100619_ses-17mo_space-T2w_desc-aseg_dseg.nii.gz
             sub-100619_ses-17mo_space-T2w_desc-aseg_volumes.json
             sub-100619_ses-17mo_space-T2w_desc-aseg_volumes.tsv
             sub-100619_ses-17mo_space-T2w_desc-brain_mask.json
             sub-100619_ses-17mo_space-T2w_desc-brain_mask.nii.gz

2 directories, 12 files
```


### What version of NiBabies are you using?

24.0.0

### Relevant log output

_No response_

### Add any additional information or context about the problem here.

Please let me know if anything else is needed, thank you!

Hints from issue comments:
@mgxd: Fixed in #400 

Solution patch:
diff --git a/nibabies/cli/parser.py b/nibabies/cli/parser.py
index 60c63910..14d3b678 100644
--- a/nibabies/cli/parser.py
+++ b/nibabies/cli/parser.py
@@ -102,19 +102,36 @@ def _drop_sub(value):
     def _drop_ses(value):
         return value[4:] if value.startswith('ses-') else value
 
-    def _filter_pybids_none_any(dct):
+    def _process_value(value):
         import bids
 
-        return {
-            k: bids.layout.Query.NONE if v is None else (bids.layout.Query.ANY if v == '*' else v)
-            for k, v in dct.items()
-        }
-
-    def _bids_filter(value):
-        from json import loads
+        if value is None:
+            return bids.layout.Query.NONE
+        elif value == '*':
+            return bids.layout.Query.ANY
+        else:
+            return value
 
-        if value and Path(value).exists():
-            return loads(Path(value).read_text(), object_hook=_filter_pybids_none_any)
+    def _filter_pybids_none_any(dct):
+        d = {}
+        for k, v in dct.items():
+            if isinstance(v, list):
+                d[k] = [_process_value(val) for val in v]
+            else:
+                d[k] = _process_value(v)
+        return d
+
+    def _bids_filter(value, parser):
+        from json import JSONDecodeError, loads
+
+        if value:
+            if Path(value).exists():
+                try:
+                    return loads(Path(value).read_text(), object_hook=_filter_pybids_none_any)
+                except JSONDecodeError as e:
+                    raise parser.error(f'JSON syntax error in: <{value}>.') from e
+            else:
+                raise parser.error(f'Path does not exist: <{value}>.')
 
     def _slice_time_ref(value, parser):
         if value == 'start':
@@ -142,6 +159,7 @@ def _slice_time_ref(value, parser):
     DirNotEmpty = partial(_dir_not_empty, parser=parser)
     IsFile = partial(_is_file, parser=parser)
     PositiveInt = partial(_min_one, parser=parser)
+    BIDSFilter = partial(_bids_filter, parser=parser)
     SliceTimeRef = partial(_slice_time_ref, parser=parser)
 
     parser.description = f"""
@@ -214,7 +232,7 @@ def _slice_time_ref(value, parser):
         '--bids-filter-file',
         dest='bids_filters',
         action='store',
-        type=_bids_filter,
+        type=BIDSFilter,
         metavar='FILE',
         help='a JSON file describing custom BIDS input filters using PyBIDS. '
         'For further details, please check out '
@@ -546,9 +564,13 @@ def _slice_time_ref(value, parser):
     g_syn = parser.add_argument_group('Specific options for SyN distortion correction')
     g_syn.add_argument(
         '--use-syn-sdc',
-        action='store_true',
+        nargs='?',
+        choices=['warn', 'error'],
+        action='store',
+        const='error',
         default=False,
-        help='EXPERIMENTAL: Use fieldmap-free distortion correction',
+        help='Use fieldmap-less distortion correction based on anatomical image; '
+        'if unable, error (default) or warn based on optional argument.',
     )
     g_syn.add_argument(
         '--force-syn',
diff --git a/nibabies/conftest.py b/nibabies/conftest.py
index a9b789b2..2a24dc2f 100644
--- a/nibabies/conftest.py
+++ b/nibabies/conftest.py
@@ -1,8 +1,11 @@
 """py.test configuration"""
 
+import json
 from pathlib import Path
 from tempfile import TemporaryDirectory
 
+import nibabel as nb
+import numpy as np
 import pytest
 
 from nibabies.data import load as load_data
@@ -37,3 +40,16 @@ def _populate_namespace(doctest_namespace, data_dir):
     doctest_namespace['data_dir'] = data_dir
     doctest_namespace['test_data'] = load_data.cached('../tests/data')
     doctest_namespace['Path'] = Path
+
+
+@pytest.fixture
+def minimal_bids(tmp_path):
+    bids = tmp_path / 'bids'
+    bids.mkdir()
+    Path.write_text(
+        bids / 'dataset_description.json', json.dumps({'Name': 'Test DS', 'BIDSVersion': '1.8.0'})
+    )
+    T1w = bids / 'sub-01' / 'anat' / 'sub-01_T1w.nii.gz'
+    T1w.parent.mkdir(parents=True)
+    nb.Nifti1Image(np.zeros((5, 5, 5)), np.eye(4)).to_filename(T1w)
+    return bids
diff --git a/nibabies/interfaces/reports.py b/nibabies/interfaces/reports.py
index d7e351c4..c0e0c8a3 100644
--- a/nibabies/interfaces/reports.py
+++ b/nibabies/interfaces/reports.py
@@ -28,6 +28,7 @@
 \t<ul class="elem-desc">
 \t\t<li>Subject ID: {subject_id}</li>
 \t\t<li>Session ID: {session_id}</li>
+\t\t<li>Chronological age (months): {age}</li>
 \t\t<li>Structural images: {num_t1w} T1-weighted, {num_t2w} T2-weighted</li>
 \t\t<li>Anatomical reference space: {anat_ref}<li>
 \t\t<li>Functional series: {n_bold:d}</li>
@@ -104,6 +105,7 @@ class SubjectSummaryInputSpec(BaseInterfaceInputSpec):
         'mcribs',
         desc='surface reconstruction method',
     )
+    age = traits.Int(desc='Chronological age in months at the time of session')
 
 
 class SubjectSummaryOutputSpec(SummaryOutputSpec):
@@ -204,6 +206,7 @@ def _generate_segment(self):
         return SUBJECT_TEMPLATE.format(
             subject_id=self.inputs.subject_id,
             session_id=self.inputs.session_id,
+            age=self.inputs.age,
             num_t1w=num_t1w,
             num_t2w=num_t2w,
             anat_ref=self.inputs.anatomical_reference,
diff --git a/nibabies/workflows/anatomical/fit.py b/nibabies/workflows/anatomical/fit.py
index cfc62717..a887b942 100644
--- a/nibabies/workflows/anatomical/fit.py
+++ b/nibabies/workflows/anatomical/fit.py
@@ -554,7 +554,7 @@ def init_infant_anat_fit_wf(
             workflow.connect([
                 (t1w_buffer, ds_t1w_mask_wf, [('t1w_mask', 'inputnode.mask_file')]),
             ])  # fmt:skip
-    else:
+    else:  # T1w mask derivative has been found
         LOGGER.info('ANAT Found T1w brain mask')
         if reference_anat == 'T1w':
             desc += (
@@ -563,26 +563,19 @@ def init_infant_anat_fit_wf(
             )
         t1w_buffer.inputs.t1w_mask = t1w_mask
         apply_t1w_mask.inputs.in_mask = t1w_mask
-        workflow.connect(t1w_validate, 'out_file', apply_t1w_mask, 'in_file')
+        workflow.connect(apply_t1w_mask, 'out_file', t1w_buffer, 't1w_brain')
 
         if not t1w_preproc:
             LOGGER.info('ANAT Skipping skull-strip, INU-correction only')
-            t1w_n4_only_wf = init_n4_only_wf(
-                omp_nthreads=omp_nthreads,
-                atropos_use_random_seed=not skull_strip_fixed_seed,
-                bids_suffix='T1w',
-                name='t1w_n4_only_wf',
-            )
+            t1w_n4_wf = init_anat_preproc_wf(name='t1w_n4_wf')
             workflow.connect([
-                (apply_t1w_mask, t1w_n4_only_wf, [('out_file', 'inputnode.in_files')]),
-                (t1w_n4_only_wf, t1w_buffer, [
-                    (('outputnode.bias_corrected', pop_file), 't1w_preproc'),
-                    (('outputnode.out_file', pop_file), 't1w_brain'),
-                ]),
+                (t1w_validate, t1w_n4_wf, [('out_file', 'inputnode.in_anat')]),
+                (t1w_n4_wf, t1w_buffer, [('outputnode.anat_preproc', 't1w_preproc')]),
+                (t1w_n4_wf, apply_t1w_mask, [('outputnode.anat_preproc', 'in_file')]),
             ])  # fmt:skip
         else:
             LOGGER.info('ANAT Skipping T1w masking')
-            workflow.connect(apply_t1w_mask, 'out_file', t1w_buffer, 't1w_brain')
+            workflow.connect(t1w_validate, 'out_file', apply_t1w_mask, 'in_file')
 
     # T2w masking logic:
     #
@@ -709,33 +702,24 @@ def init_infant_anat_fit_wf(
         LOGGER.info('ANAT Found T2w brain mask')
         if reference_anat == 'T2w':
             desc += (
-                'A pre-computed T1w brain mask was provided as input and used throughout the '
+                'A pre-computed T2w brain mask was provided as input and used throughout the '
                 'workflow.'
             )
         t2w_buffer.inputs.t2w_mask = t2w_mask
         apply_t2w_mask.inputs.in_mask = t2w_mask
-        workflow.connect([
-            (t2w_validate, apply_t2w_mask, [('out_file', 'in_file')]),
-        ])  # fmt:skip
+        workflow.connect(apply_t2w_mask, 'out_file', t2w_buffer, 't2w_brain')
 
         if not t2w_preproc:
             LOGGER.info('ANAT Skipping skull-strip, INU-correction only')
-            t2w_n4_only_wf = init_n4_only_wf(
-                omp_nthreads=omp_nthreads,
-                atropos_use_random_seed=not skull_strip_fixed_seed,
-                bids_suffix='T2w',
-                name='t2w_n4_only_wf',
-            )
+            t2w_n4_wf = init_anat_preproc_wf(name='t2w_n4_wf')
             workflow.connect([
-                (apply_t2w_mask, t2w_n4_only_wf, [('out_file', 'inputnode.in_files')]),
-                (t2w_n4_only_wf, t2w_buffer, [
-                    (('outputnode.bias_corrected', pop_file), 't2w_preproc'),
-                    (('outputnode.out_file', pop_file), 't2w_brain'),
-                ]),
+                (t2w_validate, t2w_n4_wf, [('out_file', 'inputnode.in_anat')]),
+                (t2w_n4_wf, t2w_buffer, [('outputnode.anat_preproc', 't2w_preproc')]),
+                (t2w_n4_wf, apply_t2w_mask, [('outputnode.anat_preproc', 'in_file')]),
             ])  # fmt:skip
         else:
             LOGGER.info('ANAT Skipping T2w masking')
-            workflow.connect(apply_t2w_mask, 'out_file', t2w_buffer, 't2w_brain')
+            workflow.connect(t2w_validate, 'out_file', apply_t2w_mask, 'in_file')
 
     # Stage 3: Coregistration
     t1w2t2w_xfm = precomputed.get('t1w2t2w_xfm')
@@ -1643,28 +1627,19 @@ def init_infant_single_anat_fit_wf(
         desc += 'A pre-computed brain mask was provided as input and used throughout the workflow.'
         anat_buffer.inputs.anat_mask = anat_mask
         apply_mask.inputs.in_mask = anat_mask
-        workflow.connect([
-            (anat_validate, apply_mask, [('out_file', 'in_file')]),
-        ])  # fmt:skip
+        workflow.connect(apply_mask, 'out_file', anat_buffer, 'anat_brain')
 
         if not anat_preproc:
             LOGGER.info('ANAT Skipping skull-strip, INU-correction only')
-            n4_only_wf = init_n4_only_wf(
-                omp_nthreads=omp_nthreads,
-                atropos_use_random_seed=not skull_strip_fixed_seed,
-                bids_suffix=reference_anat,
-                name='n4_only_wf',
-            )
+            anat_n4_wf = init_anat_preproc_wf(name='anat_n4_wf')
             workflow.connect([
-                (apply_mask, n4_only_wf, [('out_file', 'inputnode.in_files')]),
-                (n4_only_wf, anat_buffer, [
-                    (('outputnode.bias_corrected', pop_file), 'anat_preproc'),
-                    (('outputnode.out_file', pop_file), 'anat_brain'),
-                ]),
+                (anat_validate, anat_n4_wf, [('out_file', 'inputnode.in_anat')]),
+                (anat_n4_wf, anat_buffer, [('outputnode.anat_preproc', 'anat_preproc')]),
+                (anat_n4_wf, apply_mask, [('outputnode.anat_preproc', 'in_file')]),
             ])  # fmt:skip
         else:
-            LOGGER.info('ANAT Skipping T2w masking')
-            workflow.connect(apply_mask, 'out_file', anat_buffer, 'anat_brain')
+            LOGGER.info(f'ANAT Skipping {reference_anat} masking')
+            workflow.connect(anat_validate, 'out_file', apply_mask, 'in_file')
 
     # Stage 3: Segmentation
     seg_method = 'jlf' if config.execution.segmentation_atlases_dir else 'fast'
diff --git a/nibabies/workflows/anatomical/preproc.py b/nibabies/workflows/anatomical/preproc.py
index 4e0f9daf..7cbc08aa 100644
--- a/nibabies/workflows/anatomical/preproc.py
+++ b/nibabies/workflows/anatomical/preproc.py
@@ -12,11 +12,9 @@ def init_anat_preproc_wf(
 
     This workflow accepts T1w/T2w images as inputs (either raw or a merged template) and performs:
     - Intensity clipping
-    - Denoising
     - N4 Bias Field Correction
 
-    The outputs of this workflow will be used to either create the brainmask,
-    or reconstruct the cortical surfaces.
+    The outputs of this workflow will be a structural reference used for subsequent processing.
 
     Inputs
     ------
@@ -45,7 +43,6 @@ def init_anat_preproc_wf(
     # validate image
     validate = pe.Node(ValidateImage(), name='anat_validate', run_without_submitting=True)
     clip = pe.Node(IntensityClip(p_min=10.0, p_max=99.5), name='clip')
-    # denoise = pe.Node(DenoiseImage(dimension=3, noise_model="Rician"), name="denoise")
     n4_correct = pe.Node(
         N4BiasFieldCorrection(
             dimension=3,
@@ -61,14 +58,11 @@ def init_anat_preproc_wf(
     )
     final_clip = pe.Node(IntensityClip(p_min=5.0, p_max=99.5), name='final_clip')
 
-    # fmt:off
     wf.connect([
         (inputnode, validate, [('in_anat', 'in_file')]),
         (validate, clip, [('out_file', 'in_file')]),
         (clip, n4_correct, [('out_file', 'input_image')]),
-        # (denoise, n4_correct, [("output_image", "input_image")]),
         (n4_correct, final_clip, [('output_image', 'in_file')]),
         (final_clip, outputnode, [('out_file', 'anat_preproc')]),
-    ])
-    # fmt:on
+    ])  # fmt:skip
     return wf
diff --git a/nibabies/workflows/base.py b/nibabies/workflows/base.py
index 2742ee00..08ef9c4f 100644
--- a/nibabies/workflows/base.py
+++ b/nibabies/workflows/base.py
@@ -381,6 +381,7 @@ def init_single_subject_wf(
             recon_method=recon_method,
             std_spaces=spaces.get_spaces(nonstandard=False),
             nstd_spaces=spaces.get_spaces(standard=False),
+            age=age,
         ),
         name='summary',
         run_without_submitting=True,


Test patch:
diff --git a/nibabies/cli/tests/__init__.py b/nibabies/cli/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/nibabies/cli/tests/test_parser.py b/nibabies/cli/tests/test_parser.py
new file mode 100644
index 00000000..f0caabf9
--- /dev/null
+++ b/nibabies/cli/tests/test_parser.py
@@ -0,0 +1,272 @@
+# emacs: -*- mode: python; py-indent-offset: 4; indent-tabs-mode: nil -*-
+# vi: set ft=python sts=4 ts=4 sw=4 et:
+#
+# Copyright The NiPreps Developers <nipreps@gmail.com>
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# We support and encourage derived works from this project, please read
+# about our expectations at
+#
+#     https://www.nipreps.org/community/licensing/
+#
+"""Test parser."""
+
+from argparse import ArgumentError
+from contextlib import nullcontext
+
+import pytest
+from packaging.version import Version
+
+from nibabies import config
+from nibabies.cli import version as _version
+from nibabies.cli.parser import _build_parser, parse_args
+from nibabies.tests.test_config import _reset_config
+
+MIN_ARGS = ['data/', 'out/', 'participant']
+
+
+@pytest.mark.parametrize(
+    ('args', 'code'),
+    [
+        ([], 2),
+        (MIN_ARGS, 2),  # bids_dir does not exist
+        (MIN_ARGS + ['--fs-license-file'], 2),
+        (MIN_ARGS + ['--fs-license-file', 'fslicense.txt'], 2),
+    ],
+)
+def test_parser_errors(args, code):
+    """Check behavior of the parser."""
+    with pytest.raises(SystemExit) as error:
+        _build_parser().parse_args(args)
+
+    assert error.value.code == code
+
+
+@pytest.mark.parametrize('args', [MIN_ARGS, MIN_ARGS + ['--fs-license-file']])
+def test_parser_valid(tmp_path, args):
+    """Check valid arguments."""
+    datapath = tmp_path / 'data'
+    datapath.mkdir(exist_ok=True)
+    args[0] = str(datapath)
+
+    if '--fs-license-file' in args:
+        _fs_file = tmp_path / 'license.txt'
+        _fs_file.write_text('')
+        args.insert(args.index('--fs-license-file') + 1, str(_fs_file.absolute()))
+
+    opts = _build_parser().parse_args(args)
+
+    assert opts.bids_dir == datapath
+
+
+@pytest.mark.parametrize(
+    ('argval', 'gb'),
+    [
+        ('1G', 1),
+        ('1GB', 1),
+        ('1000', 1),  # Default units are MB
+        ('32000', 32),  # Default units are MB
+        ('4000', 4),  # Default units are MB
+        ('1000M', 1),
+        ('1000MB', 1),
+        ('1T', 1000),
+        ('1TB', 1000),
+        ('%dK' % 1e6, 1),
+        ('%dKB' % 1e6, 1),
+        ('%dB' % 1e9, 1),
+    ],
+)
+def test_memory_arg(tmp_path, argval, gb):
+    """Check the correct parsing of the memory argument."""
+    datapath = tmp_path / 'data'
+    datapath.mkdir(exist_ok=True)
+    _fs_file = tmp_path / 'license.txt'
+    _fs_file.write_text('')
+
+    args = [str(datapath)] + MIN_ARGS[1:] + ['--fs-license-file', str(_fs_file), '--mem', argval]
+    opts = _build_parser().parse_args(args)
+
+    assert opts.memory_gb == gb
+
+
+@pytest.mark.parametrize(('current', 'latest'), [('1.0.0', '1.3.2'), ('1.3.2', '1.3.2')])
+def test_get_parser_update(monkeypatch, capsys, current, latest):
+    """Make sure the out-of-date banner is shown."""
+    expectation = Version(current) < Version(latest)
+
+    def _mock_check_latest(*args, **kwargs):
+        return Version(latest)
+
+    monkeypatch.setattr(config.environment, 'version', current)
+    monkeypatch.setattr(_version, 'check_latest', _mock_check_latest)
+
+    _build_parser()
+    captured = capsys.readouterr().err
+
+    msg = f"""\
+You are using NiBabies-{current}, and a newer version of NiBabies is available: {latest}.
+Please check out our documentation about how and when to upgrade:
+https://fmriprep.readthedocs.io/en/latest/faq.html#upgrading"""
+
+    assert (msg in captured) is expectation
+
+
+@pytest.mark.parametrize('flagged', [(True, None), (True, 'random reason'), (False, None)])
+def test_get_parser_blacklist(monkeypatch, capsys, flagged):
+    """Make sure the blacklisting banner is shown."""
+
+    def _mock_is_bl(*args, **kwargs):
+        return flagged
+
+    monkeypatch.setattr(_version, 'is_flagged', _mock_is_bl)
+
+    _build_parser()
+    captured = capsys.readouterr().err
+
+    assert ('FLAGGED' in captured) is flagged[0]
+    if flagged[0]:
+        assert (flagged[1] or 'reason: unknown') in captured
+
+
+def test_parse_args(tmp_path, minimal_bids):
+    """Basic smoke test showing that our parse_args() function
+    implements the BIDS App protocol"""
+    out_dir = tmp_path / 'out'
+    work_dir = tmp_path / 'work'
+
+    parse_args(
+        args=[
+            str(minimal_bids),
+            str(out_dir),
+            'participant',  # BIDS App
+            '-w',
+            str(work_dir),  # Don't pollute CWD
+            '--skip-bids-validation',  # Empty files make BIDS sad
+        ]
+    )
+    assert config.execution.layout.root == str(minimal_bids)
+    _reset_config()
+
+
+def test_bids_filter_file(tmp_path, capsys):
+    bids_path = tmp_path / 'data'
+    out_path = tmp_path / 'out'
+    bff = tmp_path / 'filter.json'
+    args = [str(bids_path), str(out_path), 'participant', '--bids-filter-file', str(bff)]
+    bids_path.mkdir()
+
+    parser = _build_parser()
+
+    with pytest.raises(SystemExit):
+        parser.parse_args(args)
+
+    err = capsys.readouterr().err
+    assert 'Path does not exist:' in err
+
+    bff.write_text('{"invalid json": }')
+
+    with pytest.raises(SystemExit):
+        parser.parse_args(args)
+
+    err = capsys.readouterr().err
+    assert 'JSON syntax error in:' in err
+    _reset_config()
+
+
+@pytest.mark.parametrize('st_ref', (None, '0', '1', '0.5', 'start', 'middle'))  # noqa: PT007
+def test_slice_time_ref(tmp_path, st_ref):
+    bids_path = tmp_path / 'data'
+    out_path = tmp_path / 'out'
+    args = [str(bids_path), str(out_path), 'participant']
+    if st_ref:
+        args.extend(['--slice-time-ref', st_ref])
+    bids_path.mkdir()
+
+    parser = _build_parser()
+
+    parser.parse_args(args)
+    _reset_config()
+
+
+@pytest.mark.parametrize(
+    ('args', 'expectation'),
+    [
+        ([], False),
+        (['--use-syn-sdc'], 'error'),
+        (['--use-syn-sdc', 'error'], 'error'),
+        (['--use-syn-sdc', 'warn'], 'warn'),
+        (['--use-syn-sdc', 'other'], (SystemExit, ArgumentError)),
+    ],
+)
+def test_use_syn_sdc(tmp_path, args, expectation):
+    bids_path = tmp_path / 'data'
+    out_path = tmp_path / 'out'
+    args = [str(bids_path), str(out_path), 'participant'] + args
+    bids_path.mkdir()
+
+    parser = _build_parser()
+
+    cm = nullcontext()
+    if isinstance(expectation, tuple):
+        cm = pytest.raises(expectation)
+
+    with cm:
+        opts = parser.parse_args(args)
+
+    if not isinstance(expectation, tuple):
+        assert opts.use_syn_sdc == expectation
+
+    _reset_config()
+
+
+def test_derivatives(tmp_path):
+    """Check the correct parsing of the derivatives argument."""
+    bids_path = tmp_path / 'data'
+    out_path = tmp_path / 'out'
+    args = [str(bids_path), str(out_path), 'participant']
+    bids_path.mkdir()
+
+    parser = _build_parser()
+
+    # Providing --derivatives without a path should raise an error
+    temp_args = args + ['--derivatives']
+    with pytest.raises((SystemExit, ArgumentError)):
+        parser.parse_args(temp_args)
+    _reset_config()
+
+    # Providing --derivatives without names should automatically label them
+    temp_args = args + ['--derivatives', str(bids_path / 'derivatives/smriprep')]
+    opts = parser.parse_args(temp_args)
+    assert opts.derivatives == {'smriprep': bids_path / 'derivatives/smriprep'}
+    _reset_config()
+
+    # Providing --derivatives with names should use them
+    temp_args = args + [
+        '--derivatives',
+        f'anat={str(bids_path / "derivatives/smriprep")}',
+    ]
+    opts = parser.parse_args(temp_args)
+    assert opts.derivatives == {'anat': bids_path / 'derivatives/smriprep'}
+    _reset_config()
+
+    # Providing multiple unlabeled derivatives with the same name should raise an error
+    temp_args = args + [
+        '--derivatives',
+        str(bids_path / 'derivatives_01/smriprep'),
+        str(bids_path / 'derivatives_02/smriprep'),
+    ]
+    with pytest.raises(ValueError, match='Received duplicate derivative name'):
+        parser.parse_args(temp_args)
+
+    _reset_config()