Repository: dandi/dandi-cli
Issue: upload doesn't seem to be checking for valid DANDI (when not BIDS) organization of files/assets

it seems that some aspects of dandiset naming/layout may be falling through the cracks. here is another example: https://dandiarchive.org/dandiset/000168/draft

as part of healthcheck if may be worthwhile considering if files should have a different name, but doesn't. this could capture a few things: 1) dandisets like this one, 2) dandisets that were uploaded in parts, and may need name resolution before upload.

Changed files:
dandi/tests/fixtures.py, dandi/files/_private.py, dandi/files/__init__.py, dandi/files/bases.py, dandi/organize.py, dandi/validate_types.py, dandi/tests/test_organize.py, dandi/cli/tests/test_command.py, dandi/validate.py, dandi/cli/tests/test_cmd_validate.py, dandi/tests/test_files.py

diff --git a/dandi/cli/tests/test_cmd_validate.py b/dandi/cli/tests/test_cmd_validate.py
index 77d2dc5..8bb9fa8 100644
--- a/dandi/cli/tests/test_cmd_validate.py
+++ b/dandi/cli/tests/test_cmd_validate.py
@@ -1,5 +1,6 @@
 import json
 import os
+from pathlib import Path
 
 from click.testing import CliRunner
 import pytest
@@ -50,17 +51,16 @@ def test_validate_bids_grouping_error(bids_error_examples, dataset="invalid_asl0
     assert dataset in r.output
 
 
-def test_validate_nwb_path_grouping(simple4_nwb):
+def test_validate_nwb_path_grouping(organized_nwb_dir3: Path) -> None:
     """
-    This is currently a placeholder test, and should be updated once we have paths with
-    multiple errors for which grouping functionality can actually be tested.
+    This is currently a placeholder test and should be updated once we have
+    paths with multiple errors for which grouping functionality can actually be
+    tested.
     """
-
-    r = CliRunner().invoke(validate, ["--grouping=path", simple4_nwb])
+    r = CliRunner().invoke(validate, ["--grouping=path", str(organized_nwb_dir3)])
     assert r.exit_code == 0
-
     # Does it give required warnings for required path?
-    assert simple4_nwb in r.output
+    assert str(organized_nwb_dir3 / "sub-mouse001" / "sub-mouse001.nwb") in r.output
     assert "NWBI.check_data_orientation" in r.output
 
 
diff --git a/dandi/cli/tests/test_command.py b/dandi/cli/tests/test_command.py
index 55e023b..95e9b43 100644
--- a/dandi/cli/tests/test_command.py
+++ b/dandi/cli/tests/test_command.py
@@ -10,9 +10,9 @@ from ..command import __all_commands__, ls, validate
 
 
 @pytest.mark.parametrize("command", (ls, validate))
-def test_smoke(simple2_nwb, command):
+def test_smoke(organized_nwb_dir, command):
     runner = CliRunner()
-    r = runner.invoke(command, [simple2_nwb])
+    r = runner.invoke(command, [str(organized_nwb_dir)])
     assert r.exit_code == 0, f"Exited abnormally. out={r.stdout}"
     assert r.stdout, "There were no output whatsoever"
 
diff --git a/dandi/files/__init__.py b/dandi/files/__init__.py
index 67f8b7e..1d996b8 100644
--- a/dandi/files/__init__.py
+++ b/dandi/files/__init__.py
@@ -175,18 +175,19 @@ def dandi_file(
     """
     filepath = Path(filepath)
     if dandiset_path is not None:
+        dandiset_path = Path(dandiset_path)
         path = filepath.relative_to(dandiset_path).as_posix()
         if path == ".":
             raise ValueError("Dandi file path cannot equal Dandiset path")
     else:
         path = filepath.name
     if filepath.is_file() and path == dandiset_metadata_file:
-        return DandisetMetadataFile(filepath=filepath)
+        return DandisetMetadataFile(filepath=filepath, dandiset_path=dandiset_path)
     if bids_dataset_description is None:
         factory = DandiFileFactory()
     else:
         factory = BIDSFileFactory(bids_dataset_description)
-    return factory(filepath, path)
+    return factory(filepath, path, dandiset_path)
 
 
 def find_bids_dataset_description(
diff --git a/dandi/files/_private.py b/dandi/files/_private.py
index ebee591..b259ded 100644
--- a/dandi/files/_private.py
+++ b/dandi/files/_private.py
@@ -4,7 +4,7 @@ from collections.abc import Mapping
 from dataclasses import dataclass
 from enum import Enum
 from pathlib import Path
-from typing import ClassVar
+from typing import ClassVar, Optional
 import weakref
 
 from dandi.consts import (
@@ -65,9 +65,11 @@ class DandiFileFactory:
         DandiFileType.BIDS_DATASET_DESCRIPTION: BIDSDatasetDescriptionAsset,
     }
 
-    def __call__(self, filepath: Path, path: str) -> DandiFile:
+    def __call__(
+        self, filepath: Path, path: str, dandiset_path: Optional[Path]
+    ) -> DandiFile:
         return self.CLASSES[DandiFileType.classify(filepath)](
-            filepath=filepath, path=path
+            filepath=filepath, path=path, dandiset_path=dandiset_path
         )
 
 
@@ -84,16 +86,21 @@ class BIDSFileFactory(DandiFileFactory):
         DandiFileType.GENERIC: GenericBIDSAsset,
     }
 
-    def __call__(self, filepath: Path, path: str) -> DandiFile:
+    def __call__(
+        self, filepath: Path, path: str, dandiset_path: Optional[Path]
+    ) -> DandiFile:
         ftype = DandiFileType.classify(filepath)
         if ftype is DandiFileType.BIDS_DATASET_DESCRIPTION:
             if filepath == self.bids_dataset_description.filepath:
                 return self.bids_dataset_description
             else:
-                return BIDSDatasetDescriptionAsset(filepath=filepath, path=path)
+                return BIDSDatasetDescriptionAsset(
+                    filepath=filepath, path=path, dandiset_path=dandiset_path
+                )
         df = self.CLASSES[ftype](
             filepath=filepath,
             path=path,
+            dandiset_path=dandiset_path,
             bids_dataset_description_ref=weakref.ref(self.bids_dataset_description),
         )
         self.bids_dataset_description.dataset_files.append(df)
@@ -102,5 +109,5 @@ class BIDSFileFactory(DandiFileFactory):
 
 def is_empty_zarr(path: Path) -> bool:
     """:meta private:"""
-    zf = ZarrAsset(filepath=path, path=path.name)
+    zf = ZarrAsset(filepath=path, path=path.name, dandiset_path=None)
     return not any(zf.iterfiles())
diff --git a/dandi/files/bases.py b/dandi/files/bases.py
index b94c81a..56dc9f6 100644
--- a/dandi/files/bases.py
+++ b/dandi/files/bases.py
@@ -29,6 +29,7 @@ import dandi
 from dandi.dandiapi import RemoteAsset, RemoteDandiset, RESTFullAPIClient
 from dandi.metadata import get_default_metadata, nwb2asset
 from dandi.misctypes import DUMMY_DIGEST, Digest, P
+from dandi.organize import validate_organized_path
 from dandi.pynwb_utils import validate as pynwb_validate
 from dandi.support.digests import get_dandietag, get_digest
 from dandi.utils import yaml_load
@@ -56,6 +57,9 @@ class DandiFile(ABC):
     #: The path to the actual file or directory on disk
     filepath: Path
 
+    #: The path to the root of the Dandiset, if there is one
+    dandiset_path: Optional[Path]
+
     @property
     def size(self) -> int:
         """The size of the file"""
@@ -190,7 +194,7 @@ class LocalAsset(DandiFile):
                         path=self.filepath,  # note that it is not relative .path
                         message=str(e),
                         # TODO? dataset_path=dataset_path,
-                        # TODO? dandiset_path=dandiset_path,
+                        dandiset_path=self.dandiset_path,
                     )
                 ]
             except Exception as e:
@@ -215,7 +219,7 @@ class LocalAsset(DandiFile):
                         path=self.filepath,  # note that it is not relative .path
                         message=f"Failed to read metadata: {e}",
                         # TODO? dataset_path=dataset_path,
-                        # TODO? dandiset_path=dandiset_path,
+                        dandiset_path=self.dandiset_path,
                     )
                 ]
             return []
@@ -537,6 +541,29 @@ class NWBAsset(LocalFileAsset):
                     raise
                 # TODO: might reraise instead of making it into an error
                 return _pydantic_errors_to_validation_results([e], str(self.filepath))
+
+        from .bids import NWBBIDSAsset
+
+        if not isinstance(self, NWBBIDSAsset):
+            if self.dandiset_path is None:
+                errors.append(
+                    ValidationResult(
+                        id="DANDI.NO_DANDISET_FOUND",
+                        origin=ValidationOrigin(
+                            name="dandi", version=dandi.__version__
+                        ),
+                        severity=Severity.ERROR,
+                        scope=Scope.FILE,
+                        path=self.filepath,
+                        message="File is not inside a Dandiset",
+                    )
+                )
+            else:
+                errors.extend(
+                    validate_organized_path(
+                        self.path, self.filepath, self.dandiset_path
+                    )
+                )
         return errors
 
 
diff --git a/dandi/organize.py b/dandi/organize.py
index 1a254a8..7c3ee4f 100644
--- a/dandi/organize.py
+++ b/dandi/organize.py
@@ -2,19 +2,21 @@
 ATM primarily a sandbox for some functionality for  dandi organize
 """
 
+from __future__ import annotations
+
 import binascii
 from collections import Counter
 from copy import deepcopy
 import os
 import os.path as op
-from pathlib import Path
+from pathlib import Path, PurePosixPath
 import re
 from typing import List
 import uuid
 
 import numpy as np
 
-from . import get_logger
+from . import __version__, get_logger
 from .dandiset import Dandiset
 from .exceptions import OrganizeImpossibleError
 from .metadata import get_metadata
@@ -36,6 +38,7 @@ from .utils import (
     move_file,
     yaml_load,
 )
+from .validate_types import Scope, Severity, ValidationOrigin, ValidationResult
 
 lgr = get_logger()
 
@@ -1025,3 +1028,79 @@ def organize(
         msg_(" %d invalid not considered.", skip_invalid),
         dandiset_path.rstrip("/"),
     )
+
+
+LABELREGEX = r"[^_*\\\\/<>:|\\"'?%@;.]+"
+ORGANIZED_FILENAME_REGEX = (
+    rf"sub-{LABELREGEX}"
+    rf"(_ses-{LABELREGEX})?"
+    rf"(_(tis|slice|cell|probe|obj)-{LABELREGEX})*"
+    r"(_[a-z]+(\\+[a-z]+)*)?"
+    r"\\.nwb"
+)
+ORGANIZED_FOLDER_REGEX = rf"sub-{LABELREGEX}"
+
+
+def validate_organized_path(
+    asset_path: str, filepath: Path, dandiset_path: Path
+) -> list[ValidationResult]:
+    """
+    :param str asset_path:
+        The forward-slash-separated path to the asset within its local Dandiset
+        (i.e., relative to the Dandiset's root)
+    :param pathlib.Path filepath:
+        The actual filesystem path of the asset (used to construct
+        `ValidationResult` objects)
+    :param pathlib.Path dandiset_path:
+        The path to the root of the Dandiset (used to construct
+        `ValidationResult` objects)
+    """
+    path = PurePosixPath(asset_path)
+    if path.suffix != ".nwb":
+        return []
+    errors = []
+    if not re.fullmatch(ORGANIZED_FILENAME_REGEX, path.name):
+        errors.append(
+            ValidationResult(
+                id="DANDI.NON_DANDI_FILENAME",
+                origin=ValidationOrigin(name="dandi", version=__version__),
+                severity=Severity.ERROR,
+                scope=Scope.FILE,
+                path=filepath,
+                message="Filename does not conform to Dandi standard",
+                path_regex=ORGANIZED_FILENAME_REGEX,
+                dandiset_path=dandiset_path,
+            )
+        )
+    if not (
+        len(path.parent.parts) == 1
+        and re.fullmatch(ORGANIZED_FOLDER_REGEX, str(path.parent))
+    ):
+        errors.append(
+            ValidationResult(
+                id="DANDI.NON_DANDI_FOLDERNAME",
+                origin=ValidationOrigin(name="dandi", version=__version__),
+                severity=Severity.ERROR,
+                scope=Scope.FOLDER,
+                path=filepath,
+                message="File is not in folder at root with subject name",
+                path_regex=ORGANIZED_FOLDER_REGEX,
+                dandiset_path=dandiset_path,
+            )
+        )
+    if not errors:
+        m = re.match(ORGANIZED_FOLDER_REGEX, path.name)
+        assert m
+        if str(path.parent) != m[0]:
+            errors.append(
+                ValidationResult(
+                    id="DANDI.METADATA_MISMATCH_SUBJECT",
+                    origin=ValidationOrigin(name="dandi", version=__version__),
+                    severity=Severity.ERROR,
+                    scope=Scope.FILE,
+                    path=filepath,
+                    message="Filename subject does not match folder name subject",
+                    dandiset_path=dandiset_path,
+                )
+            )
+    return errors
diff --git a/dandi/tests/fixtures.py b/dandi/tests/fixtures.py
index 76c02e6..9a8845f 100644
--- a/dandi/tests/fixtures.py
+++ b/dandi/tests/fixtures.py
@@ -150,11 +150,9 @@ def simple3_nwb(
 
 
 @pytest.fixture(scope="session")
-def simple4_nwb(
-    simple1_nwb_metadata: Dict[str, Any], tmp_path_factory: pytest.TempPathFactory
-) -> str:
+def simple4_nwb(tmp_path_factory: pytest.TempPathFactory) -> str:
     """
-    With, subject, subject_id, species, but including data orientation ambiguity,
+    With subject, subject_id, species, but including data orientation ambiguity,
     the only currently non-critical issue in the dandi schema for nwbinspector validation:
     NWBI.check_data_orientation
     https://github.com/NeurodataWithoutBorders/nwbinspector/blob/
@@ -229,6 +227,19 @@ def organized_nwb_dir2(
     return tmp_path
 
 
+@pytest.fixture(scope="session")
+def organized_nwb_dir3(
+    simple4_nwb: str, tmp_path_factory: pytest.TempPathFactory
+) -> Path:
+    tmp_path = tmp_path_factory.mktemp("organized_nwb_dir")
+    (tmp_path / dandiset_metadata_file).write_text("{}\\n")
+    r = CliRunner().invoke(
+        organize, ["-f", "copy", "--dandiset-path", str(tmp_path), str(simple4_nwb)]
+    )
+    assert r.exit_code == 0, r.stdout
+    return tmp_path
+
+
 if TYPE_CHECKING:
     from ..support.typing import Literal
 
diff --git a/dandi/tests/test_files.py b/dandi/tests/test_files.py
index 4c95043..41c75d0 100644
--- a/dandi/tests/test_files.py
+++ b/dandi/tests/test_files.py
@@ -65,14 +65,28 @@ def test_find_dandi_files(tmp_path: Path) -> None:
         find_dandi_files(tmp_path, dandiset_path=tmp_path), key=attrgetter("filepath")
     )
     assert files == [
-        VideoAsset(filepath=tmp_path / "glarch.mp4", path="glarch.mp4"),
-        ZarrAsset(filepath=tmp_path / "sample01.zarr", path="sample01.zarr"),
-        NWBAsset(filepath=tmp_path / "sample02.nwb", path="sample02.nwb"),
+        VideoAsset(
+            filepath=tmp_path / "glarch.mp4", path="glarch.mp4", dandiset_path=tmp_path
+        ),
+        ZarrAsset(
+            filepath=tmp_path / "sample01.zarr",
+            path="sample01.zarr",
+            dandiset_path=tmp_path,
+        ),
         NWBAsset(
-            filepath=tmp_path / "subdir" / "sample03.nwb", path="subdir/sample03.nwb"
+            filepath=tmp_path / "sample02.nwb",
+            path="sample02.nwb",
+            dandiset_path=tmp_path,
+        ),
+        NWBAsset(
+            filepath=tmp_path / "subdir" / "sample03.nwb",
+            path="subdir/sample03.nwb",
+            dandiset_path=tmp_path,
         ),
         ZarrAsset(
-            filepath=tmp_path / "subdir" / "sample04.zarr", path="subdir/sample04.zarr"
+            filepath=tmp_path / "subdir" / "sample04.zarr",
+            path="subdir/sample04.zarr",
+            dandiset_path=tmp_path,
         ),
     ]
 
@@ -81,21 +95,45 @@ def test_find_dandi_files(tmp_path: Path) -> None:
         key=attrgetter("filepath"),
     )
     assert files == [
-        GenericAsset(filepath=tmp_path / "bar.txt", path="bar.txt"),
-        DandisetMetadataFile(filepath=tmp_path / dandiset_metadata_file),
-        GenericAsset(filepath=tmp_path / "foo", path="foo"),
-        VideoAsset(filepath=tmp_path / "glarch.mp4", path="glarch.mp4"),
-        ZarrAsset(filepath=tmp_path / "sample01.zarr", path="sample01.zarr"),
-        NWBAsset(filepath=tmp_path / "sample02.nwb", path="sample02.nwb"),
         GenericAsset(
-            filepath=tmp_path / "subdir" / "cleesh.txt", path="subdir/cleesh.txt"
+            filepath=tmp_path / "bar.txt", path="bar.txt", dandiset_path=tmp_path
+        ),
+        DandisetMetadataFile(
+            filepath=tmp_path / dandiset_metadata_file, dandiset_path=tmp_path
+        ),
+        GenericAsset(filepath=tmp_path / "foo", path="foo", dandiset_path=tmp_path),
+        VideoAsset(
+            filepath=tmp_path / "glarch.mp4", path="glarch.mp4", dandiset_path=tmp_path
+        ),
+        ZarrAsset(
+            filepath=tmp_path / "sample01.zarr",
+            path="sample01.zarr",
+            dandiset_path=tmp_path,
+        ),
+        NWBAsset(
+            filepath=tmp_path / "sample02.nwb",
+            path="sample02.nwb",
+            dandiset_path=tmp_path,
+        ),
+        GenericAsset(
+            filepath=tmp_path / "subdir" / "cleesh.txt",
+            path="subdir/cleesh.txt",
+            dandiset_path=tmp_path,
+        ),
+        GenericAsset(
+            filepath=tmp_path / "subdir" / "gnusto",
+            path="subdir/gnusto",
+            dandiset_path=tmp_path,
         ),
-        GenericAsset(filepath=tmp_path / "subdir" / "gnusto", path="subdir/gnusto"),
         NWBAsset(
-            filepath=tmp_path / "subdir" / "sample03.nwb", path="subdir/sample03.nwb"
+            filepath=tmp_path / "subdir" / "sample03.nwb",
+            path="subdir/sample03.nwb",
+            dandiset_path=tmp_path,
         ),
         ZarrAsset(
-            filepath=tmp_path / "subdir" / "sample04.zarr", path="subdir/sample04.zarr"
+            filepath=tmp_path / "subdir" / "sample04.zarr",
+            path="subdir/sample04.zarr",
+            dandiset_path=tmp_path,
         ),
     ]
 
@@ -104,15 +142,31 @@ def test_find_dandi_files(tmp_path: Path) -> None:
         key=attrgetter("filepath"),
     )
     assert files == [
-        DandisetMetadataFile(filepath=tmp_path / dandiset_metadata_file),
-        VideoAsset(filepath=tmp_path / "glarch.mp4", path="glarch.mp4"),
-        ZarrAsset(filepath=tmp_path / "sample01.zarr", path="sample01.zarr"),
-        NWBAsset(filepath=tmp_path / "sample02.nwb", path="sample02.nwb"),
+        DandisetMetadataFile(
+            filepath=tmp_path / dandiset_metadata_file, dandiset_path=tmp_path
+        ),
+        VideoAsset(
+            filepath=tmp_path / "glarch.mp4", path="glarch.mp4", dandiset_path=tmp_path
+        ),
+        ZarrAsset(
+            filepath=tmp_path / "sample01.zarr",
+            path="sample01.zarr",
+            dandiset_path=tmp_path,
+        ),
         NWBAsset(
-            filepath=tmp_path / "subdir" / "sample03.nwb", path="subdir/sample03.nwb"
+            filepath=tmp_path / "sample02.nwb",
+            path="sample02.nwb",
+            dandiset_path=tmp_path,
+        ),
+        NWBAsset(
+            filepath=tmp_path / "subdir" / "sample03.nwb",
+            path="subdir/sample03.nwb",
+            dandiset_path=tmp_path,
         ),
         ZarrAsset(
-            filepath=tmp_path / "subdir" / "sample04.zarr", path="subdir/sample04.zarr"
+            filepath=tmp_path / "subdir" / "sample04.zarr",
+            path="subdir/sample04.zarr",
+            dandiset_path=tmp_path,
         ),
     ]
 
@@ -139,45 +193,53 @@ def test_find_dandi_files_with_bids(tmp_path: Path) -> None:
     )
 
     assert files == [
-        NWBAsset(filepath=tmp_path / "bar.nwb", path="bar.nwb"),
+        NWBAsset(filepath=tmp_path / "bar.nwb", path="bar.nwb", dandiset_path=tmp_path),
         BIDSDatasetDescriptionAsset(
             filepath=tmp_path / "bids1" / "dataset_description.json",
             path="bids1/dataset_description.json",
+            dandiset_path=tmp_path,
             dataset_files=ANY,
         ),
         GenericBIDSAsset(
             filepath=tmp_path / "bids1" / "file.txt",
             path="bids1/file.txt",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         ZarrBIDSAsset(
             filepath=tmp_path / "bids1" / "subdir" / "glarch.zarr",
             path="bids1/subdir/glarch.zarr",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         NWBBIDSAsset(
             filepath=tmp_path / "bids1" / "subdir" / "quux.nwb",
             path="bids1/subdir/quux.nwb",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         BIDSDatasetDescriptionAsset(
             filepath=tmp_path / "bids2" / "dataset_description.json",
             path="bids2/dataset_description.json",
+            dandiset_path=tmp_path,
             dataset_files=ANY,
         ),
         GenericBIDSAsset(
             filepath=tmp_path / "bids2" / "movie.mp4",
             path="bids2/movie.mp4",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         GenericBIDSAsset(
             filepath=tmp_path / "bids2" / "subbids" / "data.json",
             path="bids2/subbids/data.json",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         BIDSDatasetDescriptionAsset(
             filepath=tmp_path / "bids2" / "subbids" / "dataset_description.json",
             path="bids2/subbids/dataset_description.json",
+            dandiset_path=tmp_path,
             dataset_files=ANY,
         ),
     ]
@@ -187,16 +249,19 @@ def test_find_dandi_files_with_bids(tmp_path: Path) -> None:
         GenericBIDSAsset(
             filepath=tmp_path / "bids1" / "file.txt",
             path="bids1/file.txt",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         ZarrBIDSAsset(
             filepath=tmp_path / "bids1" / "subdir" / "glarch.zarr",
             path="bids1/subdir/glarch.zarr",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
         NWBBIDSAsset(
             filepath=tmp_path / "bids1" / "subdir" / "quux.nwb",
             path="bids1/subdir/quux.nwb",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
     ]
@@ -208,6 +273,7 @@ def test_find_dandi_files_with_bids(tmp_path: Path) -> None:
         GenericBIDSAsset(
             filepath=tmp_path / "bids2" / "movie.mp4",
             path="bids2/movie.mp4",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
     ]
@@ -219,6 +285,7 @@ def test_find_dandi_files_with_bids(tmp_path: Path) -> None:
         GenericBIDSAsset(
             filepath=tmp_path / "bids2" / "subbids" / "data.json",
             path="bids2/subbids/data.json",
+            dandiset_path=tmp_path,
             bids_dataset_description_ref=ANY,
         ),
     ]
@@ -248,31 +315,41 @@ def test_validate_simple1(simple1_nwb):
     errors = dandi_file(simple1_nwb).get_validation_errors(
         schema_version=get_schema_version()
     )
-    assert not errors
+    assert [e.message for e in errors] == ["File is not inside a Dandiset"]
 
 
 def test_validate_simple1_no_subject(simple1_nwb):
     errors = dandi_file(simple1_nwb).get_validation_errors()
-    assert [e.message for e in errors] == ["Subject is missing."]
+    assert sorted(e.message for e in errors) == [
+        "File is not inside a Dandiset",
+        "Subject is missing.",
+    ]
 
 
-def test_validate_simple2(simple2_nwb):
+def test_validate_simple2(organized_nwb_dir: Path) -> None:
     # this file should be ok since a Subject is included
-    errors = dandi_file(simple2_nwb).get_validation_errors()
+    errors = dandi_file(
+        organized_nwb_dir / "sub-mouse001" / "sub-mouse001.nwb",
+        dandiset_path=organized_nwb_dir,
+    ).get_validation_errors()
     assert not errors
 
 
-def test_validate_simple2_new(simple2_nwb):
+def test_validate_simple2_new(organized_nwb_dir: Path) -> None:
     # this file should be ok
-    errors = dandi_file(simple2_nwb).get_validation_errors(
-        schema_version=get_schema_version()
-    )
+    errors = dandi_file(
+        organized_nwb_dir / "sub-mouse001" / "sub-mouse001.nwb",
+        dandiset_path=organized_nwb_dir,
+    ).get_validation_errors(schema_version=get_schema_version())
     assert not errors
 
 
 def test_validate_simple3_no_subject_id(simple3_nwb):
     errors = dandi_file(simple3_nwb).get_validation_errors()
-    assert [e.message for e in errors] == ["subject_id is missing."]
+    assert sorted(e.message for e in errors) == [
+        "File is not inside a Dandiset",
+        "subject_id is missing.",
+    ]
 
 
 def test_validate_bogus(tmp_path):
diff --git a/dandi/tests/test_organize.py b/dandi/tests/test_organize.py
index 4850555..a115a75 100644
--- a/dandi/tests/test_organize.py
+++ b/dandi/tests/test_organize.py
@@ -1,3 +1,5 @@
+from __future__ import annotations
+
 from glob import glob
 import os
 import os.path as op
@@ -18,6 +20,7 @@ from ..organize import (
     detect_link_type,
     get_obj_id,
     populate_dataset_yml,
+    validate_organized_path,
 )
 from ..pynwb_utils import _get_image_series, copy_nwb_file, get_object_id
 from ..utils import find_files, on_windows, yaml_load
@@ -323,3 +326,25 @@ def test_video_organize_common(video_mode, nwbfiles_video_common):
     else:
         assert r.exit_code == 0
         print(r.stdout)
+
+
+@pytest.mark.parametrize(
+    "path,error_ids",
+    [
+        ("XCaMPgf/XCaMPgf_ANM471996_cell01.dat", []),
+        ("sub-RAT123/sub-RAT123.nwb", []),
+        ("sub-RAT123/sub-RAT124.nwb", ["DANDI.METADATA_MISMATCH_SUBJECT"]),
+        ("sub-RAT124.nwb", ["DANDI.NON_DANDI_FOLDERNAME"]),
+        ("foo/sub-RAT124.nwb", ["DANDI.NON_DANDI_FOLDERNAME"]),
+        ("foo/sub-RAT123/sub-RAT124.nwb", ["DANDI.NON_DANDI_FOLDERNAME"]),
+        ("sub-RAT123/foo/sub-RAT124.nwb", ["DANDI.NON_DANDI_FOLDERNAME"]),
+        (
+            "XCaMPgf/XCaMPgf_ANM471996_cell01.nwb",
+            ["DANDI.NON_DANDI_FILENAME", "DANDI.NON_DANDI_FOLDERNAME"],
+        ),
+        ("sub-RAT123/XCaMPgf_ANM471996_cell01.nwb", ["DANDI.NON_DANDI_FILENAME"]),
+    ],
+)
+def test_validate_organized_path(path: str, error_ids: list[str]) -> None:
+    errors = validate_organized_path(path, Path("dummy"), Path("dummyset"))
+    assert [e.id for e in errors] == error_ids
diff --git a/dandi/validate.py b/dandi/validate.py
index 85b155a..c7ad448 100644
--- a/dandi/validate.py
+++ b/dandi/validate.py
@@ -6,6 +6,7 @@ from typing import Iterator, Optional, Union
 
 import appdirs
 
+from .consts import dandiset_metadata_file
 from .files import find_dandi_files
 from .utils import find_parent_directory_containing
 from .validate_types import Scope, Severity, ValidationOrigin, ValidationResult
@@ -161,7 +162,11 @@ def validate(
     path, errors
       errors for a path
     """
-    for df in find_dandi_files(*paths, dandiset_path=None, allow_all=allow_any_path):
-        yield from df.get_validation_errors(
-            schema_version=schema_version, devel_debug=devel_debug
-        )
+    for p in paths:
+        dandiset_path = find_parent_directory_containing(dandiset_metadata_file, p)
+        for df in find_dandi_files(
+            p, dandiset_path=dandiset_path, allow_all=allow_any_path
+        ):
+            yield from df.get_validation_errors(
+                schema_version=schema_version, devel_debug=devel_debug
+            )
diff --git a/dandi/validate_types.py b/dandi/validate_types.py
index dfd920e..d63f2e9 100644
--- a/dandi/validate_types.py
+++ b/dandi/validate_types.py
@@ -18,6 +18,7 @@ class Severity(Enum):
 
 class Scope(Enum):
     FILE = "file"
+    FOLDER = "folder"
     DANDISET = "dandiset"
     DATASET = "dataset"