Repository: django/django

Issue:
# Fixed #36968 -- Improved error message when collectstatic can't find a referenced file.

#### Trac ticket number
ticket-36968

#### Branch description
A suggestion of how we could improve the message that is shown to people when the file referenced in a css/js is missing.
Wraps the ValueError for missing file when it comes from collectstatic in a 'CommandError' and updated the message to include a little more information. The CommandError will hide the traceback by default and it can still be accessed by providing --traceback

Example
<img width="1034" height="186" alt="image" src="https://github.com/user-attachments/assets/0f5d800e-4c30-45aa-ae51-4e0c1eaeedb5" />

<details><summary>Vs Old</summary>
<img width="1038" height="963" alt="image" src="https://github.com/user-attachments/assets/5b6a8d7e-5455-4363-a181-af3c7003401a" />
</details>

Open to feedback on wording. It's inspired by [whitenoise](https://github.com/evansd/whitenoise/blob/13997326100d37bc0b9edab01c886dea85bc05c3/src/whitenoise/storage.py#L190C18-L190C34)
The intent is to make it easier for people to find where the issue is in their files when things go wrong.
But where there is a suggestion on what to look for in whitenoise, I felt it was safer to give the person the information of the file missing, the file and line it was found in, and leave it for them to fix it from there.

It could be a number of things that need fixing, from most to least likely in my experience
1) An error in your code i.ie. a typo, wrong folder, or actual missing file
2) external lib references file you don't have/need, e.g. sourcemap file
3) confusion over relative paths and how storage resolves files
4) storage is not configured correctly and not picking up files you expect it to 

Trying to call out any one of these could mislead people, calling out them all is too long, and it's probably not exhaustive anyway.

#### AI Assistance Disclosure (REQUIRED)
<!-- Please select exactly ONE of the following: -->
- [X] **No AI tools were used** in preparing this PR.
- [ ] **If AI tools were used**, I have disclosed which ones, and fully reviewed and verified their output.

#### Checklist
- [X] This PR follows the [contribution guidelines](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/submitting-patches/).
- [X] This PR **does not** disclose a security vulnerability (see [vulnerability reporting](https://docs.djangoproject.com/en/stable/internals/security/)).
- [X] This PR targets the `main` branch. <!-- Backports will be evaluated and done by mergers, when necessary. -->
- [X] The commit message is written in past tense, mentions the ticket number, and ends with a period.
- [X] I have checked the "Has patch" ticket flag in the Trac system.
- [X] I have added or updated relevant tests.
- [X] I have added or updated relevant docs, including release notes if applicable. N/A
- [X] I have attached screenshots in both light and dark modes for any UI changes. N/A


Implementation patch:
diff --git a/django/contrib/staticfiles/management/commands/collectstatic.py b/django/contrib/staticfiles/management/commands/collectstatic.py
index 1c3f8ba26d53..6fcff46d93af 100644
--- a/django/contrib/staticfiles/management/commands/collectstatic.py
+++ b/django/contrib/staticfiles/management/commands/collectstatic.py
@@ -154,7 +154,11 @@ def collect(self):
                     # Add a blank line before the traceback, otherwise it's
                     # too easy to miss the relevant part of the error message.
                     self.stderr.write()
-                    raise processed
+                    # Re-raise exceptions as CommandError and display notes.
+                    message = str(processed)
+                    if hasattr(processed, "__notes__"):
+                        message += "\n" + "\n".join(processed.__notes__)
+                    raise CommandError(message) from processed
                 if processed:
                     self.log(
                         "Post-processed '%s' as '%s'" % (original_path, processed_path),
diff --git a/django/contrib/staticfiles/storage.py b/django/contrib/staticfiles/storage.py
index c889bcb4a495..e0af40638423 100644
--- a/django/contrib/staticfiles/storage.py
+++ b/django/contrib/staticfiles/storage.py
@@ -232,6 +232,16 @@ def url_converter(self, name, hashed_files, template=None, comment_blocks=None):
         if template is None:
             template = self.default_template
 
+        def _line_at_position(content, position):
+            start = content.rfind("\n", 0, position) + 1
+            end = content.find("\n", position)
+            end = end if end != -1 else len(content)
+            line_num = content.count("\n", 0, start) + 1
+            msg = f"\n{line_num}: {content[start:end]}"
+            if len(msg) > 79:
+                return f"\n{line_num}"
+            return msg
+
         def converter(matchobj):
             """
             Convert the matched URL to a normalized and hashed URL.
@@ -276,12 +286,18 @@ def converter(matchobj):
 
             # Determine the hashed name of the target file with the storage
             # backend.
-            hashed_url = self._url(
-                self._stored_name,
-                unquote(target_name),
-                force=True,
-                hashed_files=hashed_files,
-            )
+            try:
+                hashed_url = self._url(
+                    self._stored_name,
+                    unquote(target_name),
+                    force=True,
+                    hashed_files=hashed_files,
+                )
+            except ValueError as exc:
+                line = _line_at_position(matchobj.string, matchobj.start())
+                note = f"{name!r} contains this reference {matched!r} on line {line}"
+                exc.add_note(note)
+                raise exc
 
             transformed_url = "/".join(
                 url_path.split("/")[:-1] + hashed_url.split("/")[-1:]


Test patch:
diff --git a/tests/staticfiles_tests/test_storage.py b/tests/staticfiles_tests/test_storage.py
index cdb6fd3c7e2e..9db449bf9df2 100644
--- a/tests/staticfiles_tests/test_storage.py
+++ b/tests/staticfiles_tests/test_storage.py
@@ -13,7 +13,7 @@
 from django.contrib.staticfiles.management.commands.collectstatic import (
     Command as CollectstaticCommand,
 )
-from django.core.management import call_command
+from django.core.management import CommandError, call_command
 from django.test import SimpleTestCase, override_settings
 
 from .cases import CollectionTestCase
@@ -201,8 +201,10 @@ def test_template_tag_url(self):
     def test_import_loop(self):
         finders.get_finder.cache_clear()
         err = StringIO()
-        with self.assertRaisesMessage(RuntimeError, "Max post-process passes exceeded"):
+        msg = "Max post-process passes exceeded"
+        with self.assertRaisesMessage(CommandError, msg) as cm:
             call_command("collectstatic", interactive=False, verbosity=0, stderr=err)
+        self.assertIsInstance(cm.exception.__cause__, RuntimeError)
         self.assertEqual(
             "Post-processing 'bar.css, foo.css' failed!\n\n", err.getvalue()
         )
@@ -367,9 +369,14 @@ def test_post_processing_failure(self):
         """
         finders.get_finder.cache_clear()
         err = StringIO()
-        with self.assertRaises(Exception):
+        with self.assertRaises(CommandError) as cm:
             call_command("collectstatic", interactive=False, verbosity=0, stderr=err)
         self.assertEqual("Post-processing 'faulty.css' failed!\n\n", err.getvalue())
+        self.assertIsInstance(cm.exception.__cause__, ValueError)
+        exc_message = str(cm.exception)
+        self.assertIn("faulty.css", exc_message)
+        self.assertIn("missing.css", exc_message)
+        self.assertIn("1:", exc_message)  # line 1 reported
         self.assertPostCondition()
 
     @override_settings(
@@ -379,8 +386,9 @@ def test_post_processing_failure(self):
     def test_post_processing_nonutf8(self):
         finders.get_finder.cache_clear()
         err = StringIO()
-        with self.assertRaises(UnicodeDecodeError):
+        with self.assertRaises(CommandError) as cm:
             call_command("collectstatic", interactive=False, verbosity=0, stderr=err)
+        self.assertIsInstance(cm.exception.__cause__, UnicodeDecodeError)
         self.assertEqual("Post-processing 'nonutf8.css' failed!\n\n", err.getvalue())
         self.assertPostCondition()