@@ -755,6 +755,44 @@ def test_other_method_is_allowed(self) -> None:
755755 _assert_no_message_emitted (code , 'no-direct-database-rollback' )
756756
757757
758+ # === C9017: no-direct-filesystem-access-in-model ===
759+
760+ class TestNoDirectFilesystemAccessInModelInMemory :
761+ """In-memory tests for the no-direct-filesystem-access-in-model rule (C9017)."""
762+
763+ def test_import_pathlib_in_model_is_flagged (self ) -> None :
764+ code = dedent (
765+ '''\
766+ import pathlib
767+ '''
768+ )
769+ _assert_message_emitted_in_model (code , 'no-direct-filesystem-access-in-model' )
770+
771+ def test_from_pathlib_import_in_model_is_flagged (self ) -> None :
772+ code = dedent (
773+ '''\
774+ from pathlib import Path
775+ '''
776+ )
777+ _assert_message_emitted_in_model (code , 'no-direct-filesystem-access-in-model' )
778+
779+ def test_import_pathlib_outside_model_is_allowed (self ) -> None :
780+ code = dedent (
781+ '''\
782+ import pathlib
783+ '''
784+ )
785+ _assert_no_message_emitted (code , 'no-direct-filesystem-access-in-model' )
786+
787+ def test_from_pathlib_import_outside_model_is_allowed (self ) -> None :
788+ code = dedent (
789+ '''\
790+ from pathlib import Path
791+ '''
792+ )
793+ _assert_no_message_emitted (code , 'no-direct-filesystem-access-in-model' )
794+
795+
758796# === Utilities ===
759797
760798# Counter for generating unique fake filenames
@@ -774,6 +812,20 @@ def _assert_message_emitted(code: str, message_id: str) -> None:
774812 )
775813
776814
815+ def _assert_message_emitted_in_model (code : str , message_id : str ) -> None :
816+ """
817+ Assert that checker emits the specified message given the specified code,
818+ simulating a file in the model layer (crystal/model/).
819+
820+ message_id should be the symbolic name (e.g. 'no-direct-filesystem-access-in-model').
821+ """
822+ messages = _run_checker_on_code_in_model (code )
823+ message_ids = [msg .msg_id for msg in messages ]
824+ assert message_id in message_ids , (
825+ f'Expected message { message_id } not found. Got: { message_ids } '
826+ )
827+
828+
777829def _assert_no_message_emitted (code : str , message_id : str ) -> None :
778830 """
779831 Assert that checker does not emit the specified message given the specified code.
@@ -815,6 +867,26 @@ def _run_checker_on_code(code: str) -> list[pylint.testutils.MessageTest]:
815867 return test_case .linter .release_messages ()
816868
817869
870+ def _run_checker_on_code_in_model (code : str ) -> list [pylint .testutils .MessageTest ]:
871+ """
872+ Run a pylint checker on code, simulating a file in the model layer (crystal/model/).
873+ """
874+ fake_filename = f'/src/crystal/model/test_file_{ next (_fake_filename_counter )} .py'
875+
876+ code_lines = tuple (code .splitlines (keepends = True ))
877+ with mock .patch ('crystal.lint.rules._read_source_lines' , return_value = code_lines ):
878+ test_case = _InMemoryCheckerTestCase ()
879+ test_case .CHECKER_CLASS = CrystalLintRules
880+ test_case .setup_method ()
881+
882+ module = astroid .parse (code , module_name = fake_filename )
883+ module .file = fake_filename
884+
885+ test_case .walk (module )
886+
887+ return test_case .linter .release_messages ()
888+
889+
818890class _InMemoryCheckerTestCase (pylint .testutils .CheckerTestCase ):
819891 """
820892 Base class for in-memory pylint checker tests.
0 commit comments