Repository: semgrep/semgrep (language: python)

Issue:
bump version to 0.40.0
# Release Checklist
- [x] Updated CHANGELOG with latest changes
- [x] Updated `__VERSION__` in `semgrep/semgrep/__init__.py`
- [x] Updated install_requires version in `setup.py` for pre-commit in the repo root
- [x] Updated latest release and release date in `CHANGELOG.md`
- [x] Tests/CI passes on this Pull Request
- [x] Build Release Candidate finished successfully
- [x] Update semgrep.dev
 - [x] Update r2c.dev
- [ ] Update semgrep action
- [ ] Publish Github Release Page
- [x] Sanity check Pypi Packages
- [ ] Sanity check Homebrew core release
- [ ] Sanity check docker images (X.X.X and latest tag)
- [ ] Update semgrep-docs



Discussion / hints:

Fix patch:
diff --git a/.dockerignore b/.dockerignore
index 361f541075c0..e10d9af881d4 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -235,6 +235,8 @@ cli/src/semgrep/semgrep_interfaces/**/Language
 cli/src/semgrep/semgrep_interfaces/tests/**/__pycache__
 cli/src/semgrep/semgrep_interfaces/tests/**/*.ast.json
 
+# From cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/.gitignore
+cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/**/.gradle
 # From cli/tests/default/e2e/targets/ignores_default/.gitignore
 cli/tests/default/e2e/targets/ignores_default/**/ignore.js
 # From cli/tests/default/e2e/targets/ignores/.gitignore
diff --git a/changelog.d/csharp-new.added b/changelog.d/csharp-new.added
new file mode 100644
index 000000000000..31eb549095b0
--- /dev/null
+++ b/changelog.d/csharp-new.added
@@ -0,0 +1,1 @@
+[Pro only] Patterns such as `new $T(...)` will now match C# [target-typed new expressions](https://devblogs.microsoft.com/dotnet/welcome-to-c-9-0/#target-typed-new-expressions) such as `new ()`.
diff --git a/changelog.d/flow-86.added b/changelog.d/flow-86.added
new file mode 100644
index 000000000000..88b431feb5c4
--- /dev/null
+++ b/changelog.d/flow-86.added
@@ -0,0 +1,1 @@
+Symbolic propagation will now propagate record expressions.
diff --git a/changelog.d/saf-1703.fixed b/changelog.d/saf-1703.fixed
new file mode 100644
index 000000000000..97596ef446c3
--- /dev/null
+++ b/changelog.d/saf-1703.fixed
@@ -0,0 +1,1 @@
+Removed the experimental --use-osemgrep-sarif flag.
diff --git a/changelog.d/sc-1964.added b/changelog.d/sc-1964.added
new file mode 100644
index 000000000000..4394170fdf6c
--- /dev/null
+++ b/changelog.d/sc-1964.added
@@ -0,0 +1,1 @@
+Adds support for SwiftPM Package.resolved version 3 to Supply Chain
diff --git a/cli/pyproject.toml b/cli/pyproject.toml
index 68b82ef3064e..ec8561596bbd 100644
--- a/cli/pyproject.toml
+++ b/cli/pyproject.toml
@@ -28,6 +28,7 @@ markers = [
     "no_semgrep_cli: this test doesn't invoke the semgrep CLI or osemgrep",
     "osemfail: this test currently fails with osemgrep",
     "pysemfail: this test currently fails with pysemgrep",
+    "requires_lockfileless_deps: this test requires lockfileless environment dependencies (java, etc)",
 ]
 
 [tool.tox]
diff --git a/cli/setup.py b/cli/setup.py
index 69280a7a986c..522c61239367 100644
--- a/cli/setup.py
+++ b/cli/setup.py
@@ -58,7 +58,7 @@ def get_tag(self):
     raise Exception(
         "Semgrep does not support Windows yet, please try again with WSL "
         "or visit the following for more information: "
-        "https://github.com/returntocorp/semgrep/issues/1330"
+        "https://github.com/semgrep/semgrep/issues/1330"
     )
 
 try:
diff --git a/cli/src/semdep/matchers/gradle.py b/cli/src/semdep/matchers/gradle.py
index 72d8bde27ba8..8813ad21a948 100644
--- a/cli/src/semdep/matchers/gradle.py
+++ b/cli/src/semdep/matchers/gradle.py
@@ -30,14 +30,14 @@ class GradleMatcher(SubprojectMatcher):
     https://docs.gradle.org/current/userguide/intro_multi_project_builds.htm
     """
 
-    BUILD_FILENAME = "build.gradle"
-    SETTINGS_FILENAME = "settings.gradle"
+    BUILD_FILENAMES = ["build.gradle", "build.gradle.kts"]
+    SETTINGS_FILENAMES = ["settings.gradle", "settings.gradle.kts"]
     LOCKFILE_FILENAME = "gradle.lockfile"
 
     def is_match(self, path: Path) -> bool:
         return path.name in [
-            self.BUILD_FILENAME,
-            self.SETTINGS_FILENAME,
+            *self.BUILD_FILENAMES,
+            *self.SETTINGS_FILENAMES,
             self.LOCKFILE_FILENAME,
         ]
 
@@ -50,15 +50,21 @@ def _lockfile_to_settings_and_build(
 
         Returns (settings_path, build_path) if each path exists in candidates.
         """
-        possible_build_path = lockfile_path.parent / self.BUILD_FILENAME
-        possible_settings_path = lockfile_path.parent / self.SETTINGS_FILENAME
+        possible_build_paths = [lockfile_path.parent / x for x in self.BUILD_FILENAMES]
+        possible_settings_paths = [
+            lockfile_path.parent / x for x in self.SETTINGS_FILENAMES
+        ]
 
         build_path: Optional[Path] = None
         settings_path: Optional[Path] = None
-        if possible_build_path in candidates:
-            build_path = possible_build_path
-        if possible_settings_path in candidates:
-            settings_path = possible_settings_path
+        for possible_build_path in possible_build_paths:
+            if possible_build_path in candidates:
+                build_path = possible_build_path
+                break
+        for possible_settings_path in possible_settings_paths:
+            if possible_settings_path in candidates:
+                settings_path = possible_settings_path
+                break
         return settings_path, build_path
 
     def _sort_source_files(
@@ -74,9 +80,9 @@ def _sort_source_files(
         lockfiles: Set[Path] = set()
 
         for path in dep_source_files:
-            if path.name == self.BUILD_FILENAME:
+            if path.name in self.BUILD_FILENAMES:
                 build_files.add(path)
-            elif path.name == self.SETTINGS_FILENAME:
+            elif path.name in self.SETTINGS_FILENAMES:
                 settings_files.add(path)
             elif path.name == self.LOCKFILE_FILENAME:
                 lockfiles.add(path)
@@ -106,7 +112,7 @@ def make_subprojects(
             )
 
             lockfile = out.Lockfile(
-                kind=out.LockfileKind(out.GradleLockfile_()),
+                kind=out.LockfileKind(out.GradleLockfile()),
                 path=out.Fpath(str(lockfile_path)),
             )
 
@@ -157,14 +163,18 @@ def make_subprojects(
                 continue
 
             project_root = settings_path.parent
-            possible_build_path = settings_path.parent / self.BUILD_FILENAME
-            if possible_build_path in dep_source_files:
+            build_paths = dep_source_files.intersection(
+                settings_path.parent / x for x in self.BUILD_FILENAMES
+            )
+            if len(build_paths) > 0:
+                # it doesn't really make sense for there to be multiple build files, so pick one arbitrarily
+                first_build_path = next(x for x in build_paths)
                 # if a build file is present, favor using that as the manifest file, but build
                 # files might be missing for multi-project builds
-                used_build_paths.add(possible_build_path)
+                used_build_paths.add(first_build_path)
                 manifest = out.Manifest(
                     kind=out.ManifestKind(out.BuildGradle()),
-                    path=out.Fpath(str(possible_build_path)),
+                    path=out.Fpath(str(first_build_path)),
                 )
             else:
                 manifest = out.Manifest(
diff --git a/cli/src/semdep/parsers/composer.py b/cli/src/semdep/parsers/composer.py
index 378043c65e68..a3b0adf53041 100644
--- a/cli/src/semdep/parsers/composer.py
+++ b/cli/src/semdep/parsers/composer.py
@@ -16,7 +16,7 @@
 from semdep.parsers.util import safe_parse_lockfile_and_manifest
 from semdep.parsers.util import transitivity
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Composer
-from semgrep.semgrep_interfaces.semgrep_output_v1 import ComposerLock
+from semgrep.semgrep_interfaces.semgrep_output_v1 import ComposerLock_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Ecosystem
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
@@ -48,8 +48,8 @@ def parse_composer_lock(
     lockfile_path: Path, manifest_path: Optional[Path]
 ) -> Tuple[List[FoundDependency], List[DependencyParserError]]:
     parsed_lockfile, parsed_manifest, errors = safe_parse_lockfile_and_manifest(
-        DependencyFileToParse(lockfile_path, json_doc, ScaParserName(ComposerLock())),
-        DependencyFileToParse(manifest_path, json_doc, ScaParserName(ComposerLock()))
+        DependencyFileToParse(lockfile_path, json_doc, ScaParserName(ComposerLock_())),
+        DependencyFileToParse(manifest_path, json_doc, ScaParserName(ComposerLock_()))
         if manifest_path
         else None,
     )
diff --git a/cli/src/semdep/parsers/gem.py b/cli/src/semdep/parsers/gem.py
index 5af09e644ef3..8030996a2673 100644
--- a/cli/src/semdep/parsers/gem.py
+++ b/cli/src/semdep/parsers/gem.py
@@ -23,7 +23,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Gem
-from semgrep.semgrep_interfaces.semgrep_output_v1 import GemfileLock
+from semgrep.semgrep_interfaces.semgrep_output_v1 import GemfileLock_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 
 
@@ -96,7 +96,7 @@ def parse_gemfile(
     lockfile_path: Path, manifest_path: Optional[Path]
 ) -> Tuple[List[FoundDependency], List[DependencyParserError]]:
     parsed_lockfile, parsed_manifest, errors = safe_parse_lockfile_and_manifest(
-        DependencyFileToParse(lockfile_path, gemfile, ScaParserName(GemfileLock())),
+        DependencyFileToParse(lockfile_path, gemfile, ScaParserName(GemfileLock_())),
         None,
     )
     if not parsed_lockfile:
diff --git a/cli/src/semdep/parsers/go_mod.py b/cli/src/semdep/parsers/go_mod.py
index 76acc142ea69..c9f50a3f73d1 100644
--- a/cli/src/semdep/parsers/go_mod.py
+++ b/cli/src/semdep/parsers/go_mod.py
@@ -22,8 +22,8 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Ecosystem
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
-from semgrep.semgrep_interfaces.semgrep_output_v1 import GoMod
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Gomod
+from semgrep.semgrep_interfaces.semgrep_output_v1 import GoMod2
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Transitive
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Transitivity
@@ -78,7 +78,7 @@ def parse_go_mod(
     lockfile_path: Path, manifest_path: Optional[Path]
 ) -> Tuple[List[FoundDependency], List[DependencyParserError]]:
     parsed_lockfile, parsed_manifest, errors = safe_parse_lockfile_and_manifest(
-        DependencyFileToParse(lockfile_path, go_mod, ScaParserName(GoMod())), None
+        DependencyFileToParse(lockfile_path, go_mod, ScaParserName(GoMod2())), None
     )
     if not parsed_lockfile:
         return [], errors
diff --git a/cli/src/semdep/parsers/gradle.py b/cli/src/semdep/parsers/gradle.py
index d876d9abce0b..476c409bcdb7 100644
--- a/cli/src/semdep/parsers/gradle.py
+++ b/cli/src/semdep/parsers/gradle.py
@@ -24,7 +24,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Ecosystem
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
-from semgrep.semgrep_interfaces.semgrep_output_v1 import GradleLockfile
+from semgrep.semgrep_interfaces.semgrep_output_v1 import GradleLockfile_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Maven
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 from semgrep.verbose_logging import getLogger
@@ -81,8 +81,8 @@ def parse_gradle(
     lockfile_path: Path, manifest_path: Optional[Path]
 ) -> Tuple[List[FoundDependency], List[DependencyParserError]]:
     parsed_lockfile, parsed_manifest, errors = safe_parse_lockfile_and_manifest(
-        DependencyFileToParse(lockfile_path, gradle, ScaParserName(GradleLockfile())),
-        DependencyFileToParse(manifest_path, manifest, ScaParserName(GradleLockfile()))
+        DependencyFileToParse(lockfile_path, gradle, ScaParserName(GradleLockfile_())),
+        DependencyFileToParse(manifest_path, manifest, ScaParserName(GradleLockfile_()))
         if manifest_path
         else None,
     )
diff --git a/cli/src/semdep/parsers/mix.py b/cli/src/semdep/parsers/mix.py
index 4061f167557c..364afd5364b4 100644
--- a/cli/src/semdep/parsers/mix.py
+++ b/cli/src/semdep/parsers/mix.py
@@ -28,7 +28,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Hex
-from semgrep.semgrep_interfaces.semgrep_output_v1 import MixLock
+from semgrep.semgrep_interfaces.semgrep_output_v1 import MixLock_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 
 # :hex,
@@ -239,10 +239,12 @@ def parse_mix(
     lockfile_path: Path, manifest_path: Path | None
 ) -> tuple[list[FoundDependency], list[DependencyParserError]]:
     parsed_lockfile, parsed_manifest, errors = safe_parse_lockfile_and_manifest(
-        DependencyFileToParse(lockfile_path, lockfile_parser, ScaParserName(MixLock())),
+        DependencyFileToParse(
+            lockfile_path, lockfile_parser, ScaParserName(MixLock_())
+        ),
         (
             DependencyFileToParse(
-                manifest_path, manifest_parser, ScaParserName(MixLock())
+                manifest_path, manifest_parser, ScaParserName(MixLock_())
             )
             if manifest_path
             else None
diff --git a/cli/src/semdep/parsers/pipfile.py b/cli/src/semdep/parsers/pipfile.py
index 08748a5827f3..ef6575226386 100644
--- a/cli/src/semdep/parsers/pipfile.py
+++ b/cli/src/semdep/parsers/pipfile.py
@@ -25,7 +25,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Jsondoc
-from semgrep.semgrep_interfaces.semgrep_output_v1 import Pipfile
+from semgrep.semgrep_interfaces.semgrep_output_v1 import Pipfile_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Pypi
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 from semgrep.verbose_logging import getLogger
@@ -73,7 +73,7 @@ def parse_pipfile(
         DependencyFileToParse(
             manifest_path,
             manifest,
-            ScaParserName(Pipfile()),
+            ScaParserName(Pipfile_()),
             preprocessors.CommentRemover(),
         )
         if manifest_path
diff --git a/cli/src/semdep/parsers/pnpm.py b/cli/src/semdep/parsers/pnpm.py
index 1877f227e464..6f583a71ca84 100644
--- a/cli/src/semdep/parsers/pnpm.py
+++ b/cli/src/semdep/parsers/pnpm.py
@@ -19,7 +19,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Npm
-from semgrep.semgrep_interfaces.semgrep_output_v1 import PnpmLock
+from semgrep.semgrep_interfaces.semgrep_output_v1 import PnpmLock_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 
 
@@ -69,7 +69,7 @@ def parse_pnpm(
             lambda text: parse_yaml_preserve_spans(
                 text, str(lockfile_path), allow_null=True
             ),
-            ScaParserName(PnpmLock()),
+            ScaParserName(PnpmLock_()),
         ),
         None,
     )
@@ -119,7 +119,7 @@ def parse_pnpm(
                     errors.append(
                         DependencyParserError(
                             path=str(lockfile_path),
-                            parser=ScaParserName(PnpmLock()),
+                            parser=ScaParserName(PnpmLock_()),
                             reason=f"Could not parse package key {key.value}",
                             line=line,
                         )
diff --git a/cli/src/semdep/parsers/poetry.py b/cli/src/semdep/parsers/poetry.py
index f2a6075eff85..d3b772afb13b 100644
--- a/cli/src/semdep/parsers/poetry.py
+++ b/cli/src/semdep/parsers/poetry.py
@@ -26,9 +26,9 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Ecosystem
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
-from semgrep.semgrep_interfaces.semgrep_output_v1 import PoetryLock
+from semgrep.semgrep_interfaces.semgrep_output_v1 import PoetryLock_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Pypi
-from semgrep.semgrep_interfaces.semgrep_output_v1 import PyprojectToml
+from semgrep.semgrep_interfaces.semgrep_output_v1 import PyprojectToml_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 
 
@@ -194,13 +194,13 @@ def parse_poetry(
         DependencyFileToParse(
             lockfile_path,
             poetry,
-            ScaParserName(PoetryLock()),
+            ScaParserName(PoetryLock_()),
             preprocessors.CommentRemover(),
         ),
         DependencyFileToParse(
             manifest_path,
             manifest,
-            ScaParserName(PyprojectToml()),
+            ScaParserName(PyprojectToml_()),
             preprocessors.CommentRemover(),
         )
         if manifest_path
diff --git a/cli/src/semdep/parsers/pubspec_lock.py b/cli/src/semdep/parsers/pubspec_lock.py
index 5aaeb652c8fb..fd99774e20ec 100644
--- a/cli/src/semdep/parsers/pubspec_lock.py
+++ b/cli/src/semdep/parsers/pubspec_lock.py
@@ -16,7 +16,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Pub
-from semgrep.semgrep_interfaces.semgrep_output_v1 import PubspecLock
+from semgrep.semgrep_interfaces.semgrep_output_v1 import PubspecLock_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Transitive
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Transitivity
@@ -32,7 +32,7 @@ def parse_pubspec_lock(
             lambda text: parse_yaml_preserve_spans(
                 text, str(lockfile_path), allow_null=True
             ),
-            ScaParserName(PubspecLock()),
+            ScaParserName(PubspecLock_()),
         ),
         None,
     )
diff --git a/cli/src/semdep/parsers/swiftpm.py b/cli/src/semdep/parsers/swiftpm.py
index 80c8b1e1fef3..8fd10e8fd23d 100644
--- a/cli/src/semdep/parsers/swiftpm.py
+++ b/cli/src/semdep/parsers/swiftpm.py
@@ -29,7 +29,7 @@
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Fpath
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Jsondoc
 from semgrep.semgrep_interfaces.semgrep_output_v1 import PackageResolved
-from semgrep.semgrep_interfaces.semgrep_output_v1 import PackageSwift
+from semgrep.semgrep_interfaces.semgrep_output_v1 import PackageSwift_
 from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
 from semgrep.semgrep_interfaces.semgrep_output_v1 import SwiftPM
 from semgrep.verbose_logging import getLogger
@@ -109,13 +109,24 @@
     any_char.until(regex(r"dependencies\s*:")) >> dependencies_block << any_char.many()
 )
 
+# versions of Package.resolved are defined in swift source code at
+# https://github.com/swiftlang/swift-package-manager/blob/4c206fb9edf118b213485f295295e41eed995bb1/Sources/PackageGraph/ResolvedPackagesStore.swift#L319
 
-def parse_swiftpm_v2(
+
+def parse_swiftpm_v2_v3(
     lockfile_path: Path,
     lockfile: Dict[str, JSON],
     direct_deps: Set[str],
     manifest_path: Optional[Path],
 ) -> List[FoundDependency]:
+    """
+    Parse a SwiftPM Package.resolved file of version 2 or version 3. The only difference between v2
+    and v3 is a single 'originHash' field that is used as a performance optimization in SwiftPM and that
+    we do not care about, so the same parser is used for v2 and v3.
+
+    See https://github.com/swiftlang/swift-package-manager/blob/4c206fb9edf118b213485f295295e41eed995bb1/Sources/PackageGraph/ResolvedPackagesStore.swift#L421-L519
+    for the source code defining V2 and V3 to see the difference. The PR that introduced V3 can be found at https://github.com/swiftlang/swift-package-manager/pull/6698
+    """
     result = []
 
     deps = lockfile.get("pins")
@@ -227,7 +238,7 @@ def parse_package_resolved(
     parsed_lockfile, parsed_manifest, errors = safe_parse_lockfile_and_manifest(
         DependencyFileToParse(lockfile_path, json_doc, ScaParserName(Jsondoc())),
         DependencyFileToParse(
-            manifest_path, package_swift_parser, ScaParserName(PackageSwift())
+            manifest_path, package_swift_parser, ScaParserName(PackageSwift_())
         )
         if manifest_path
         else None,
@@ -259,8 +270,8 @@ def parse_package_resolved(
         all_deps = parse_swiftpm_v1(
             lockfile_path, lockfile_json, direct_deps, manifest_path
         )
-    elif lockfile_version_int == 2:
-        all_deps = parse_swiftpm_v2(
+    elif lockfile_version_int == 2 or lockfile_version_int == 3:
+        all_deps = parse_swiftpm_v2_v3(
             lockfile_path, lockfile_json, direct_deps, manifest_path
         )
     else:
@@ -268,7 +279,7 @@ def parse_package_resolved(
             DependencyParserError(
                 str(lockfile_path),
                 ScaParserName(PackageResolved()),
-                "Invalid lockfile version. Expected 1 or 2.",
+                "Invalid lockfile version. Expected 1, 2, or 3.",
             )
         )
 
diff --git a/cli/src/semdep/subproject_matchers.py b/cli/src/semdep/subproject_matchers.py
index 5551e84ce682..53ff6de0a93f 100644
--- a/cli/src/semdep/subproject_matchers.py
+++ b/cli/src/semdep/subproject_matchers.py
@@ -36,22 +36,22 @@
     ExactLockfileManifestMatcher(
         lockfile_name="pnpm-lock.yaml",
         manifest_name="package.json",
-        lockfile_kind=out.LockfileKind(out.PnpmLock_()),
+        lockfile_kind=out.LockfileKind(out.PnpmLock()),
         manifest_kind=out.ManifestKind(out.PackageJson()),
     ),
     # Gem
     ExactLockfileManifestMatcher(
         lockfile_name="Gemfile.lock",
         manifest_name="Gemfile",
-        lockfile_kind=out.LockfileKind(out.GemfileLock_()),
+        lockfile_kind=out.LockfileKind(out.GemfileLock()),
         manifest_kind=out.ManifestKind(out.Gemfile()),
     ),
     # Go modules
     ExactLockfileManifestMatcher(
         lockfile_name="go.mod",
         manifest_name="go.mod",
-        lockfile_kind=out.LockfileKind(out.GoMod2()),
-        manifest_kind=out.ManifestKind(out.GoMod_()),
+        lockfile_kind=out.LockfileKind(out.GoMod_()),
+        manifest_kind=out.ManifestKind(out.GoMod()),
     ),
     # Cargo
     ExactLockfileManifestMatcher(
@@ -76,7 +76,7 @@
     ExactLockfileManifestMatcher(
         lockfile_name="composer.lock",
         manifest_name="composer.json",
-        lockfile_kind=out.LockfileKind(out.ComposerLock_()),
+        lockfile_kind=out.LockfileKind(out.ComposerLock()),
         manifest_kind=out.ManifestKind(out.ComposerJson()),
     ),
     # Nuget
@@ -90,7 +90,7 @@
     ExactLockfileManifestMatcher(
         lockfile_name="pubspec.lock",
         manifest_name="pubspec.yaml",
-        lockfile_kind=out.LockfileKind(out.PubspecLock_()),
+        lockfile_kind=out.LockfileKind(out.PubspecLock()),
         manifest_kind=out.ManifestKind(out.PubspecYaml()),
     ),
     # Swift PM
@@ -98,13 +98,13 @@
         lockfile_name="Package.resolved",
         manifest_name="Package.swift",
         lockfile_kind=out.LockfileKind(out.SwiftPackageResolved()),
-        manifest_kind=out.ManifestKind(out.PackageSwift_()),
+        manifest_kind=out.ManifestKind(out.PackageSwift()),
     ),
     # Hex
     ExactLockfileManifestMatcher(
         lockfile_name="mix.lock",
         manifest_name="mix.exs",
-        lockfile_kind=out.LockfileKind(out.MixLock_()),
+        lockfile_kind=out.LockfileKind(out.MixLock()),
         manifest_kind=out.ManifestKind(out.MixExs()),
     ),
     # Pipenv
@@ -112,14 +112,14 @@
         lockfile_name="Pipfile.lock",
         manifest_name="Pipfile",
         lockfile_kind=out.LockfileKind(out.PipfileLock()),
-        manifest_kind=out.ManifestKind(out.Pipfile_()),
+        manifest_kind=out.ManifestKind(out.Pipfile()),
     ),
     # Poetry
     ExactLockfileManifestMatcher(
         lockfile_name="poetry.lock",
         manifest_name="pyproject.toml",
-        lockfile_kind=out.LockfileKind(out.PoetryLock_()),
-        manifest_kind=out.ManifestKind(out.PyprojectToml_()),
+        lockfile_kind=out.LockfileKind(out.PoetryLock()),
+        manifest_kind=out.ManifestKind(out.PyprojectToml()),
     ),
 ]
 
diff --git a/cli/src/semgrep/app/scans.py b/cli/src/semgrep/app/scans.py
index 1d735cba9d09..f031b9840651 100644
--- a/cli/src/semgrep/app/scans.py
+++ b/cli/src/semgrep/app/scans.py
@@ -14,6 +14,7 @@
 from typing import Optional
 from typing import Set
 from typing import TYPE_CHECKING
+from typing import Union
 
 import click
 import requests
@@ -33,13 +34,15 @@
 from semgrep.rule import Rule
 from semgrep.rule_match import RuleMatchMap
 from semgrep.state import get_state
+from semgrep.subproject import ResolvedSubproject
+from semgrep.subproject import UnresolvedSubproject
 from semgrep.target_manager import ALL_PRODUCTS
 from semgrep.verbose_logging import getLogger
 
+
 if TYPE_CHECKING:
     from semgrep.engine import EngineType
     from rich.progress import Progress
-
 logger = getLogger(__name__)
 
 
@@ -346,6 +349,7 @@ def report_findings(
         commit_date: str,
         lockfile_dependencies: Dict[str, List[out.FoundDependency]],
         dependency_parser_errors: List[DependencyParserError],
+        all_subprojects: List[Union[UnresolvedSubproject, ResolvedSubproject]],
         contributions: out.Contributions,
         engine_requested: "EngineType",
         progress_bar: "Progress",
@@ -459,6 +463,11 @@ def report_findings(
             name = USER_FRIENDLY_PRODUCT_NAMES.get(r.product, r.product.to_json())
             findings_by_product[f"{name}"] += len(f)
 
+        subproject_stats: List[out.SubprojectStats] = []
+        if all_subprojects:
+            for subproject in all_subprojects:
+                subproject_stats.append(subproject.to_stats_output())
+
         complete = out.CiScanComplete(
             exit_code=cli_suggested_exit_code,
             dependency_parser_errors=dependency_parser_errors,
@@ -481,6 +490,9 @@ def report_findings(
                 },
                 engine_requested=engine_requested.name,
                 findings_by_product=findings_by_product,
+                supply_chain_stats=out.SupplyChainStats(
+                    subprojects_stats=subproject_stats
+                ),
             ),
         )
 
diff --git a/cli/src/semgrep/commands/ci.py b/cli/src/semgrep/commands/ci.py
index a643f49430ec..646f6aab8355 100644
--- a/cli/src/semgrep/commands/ci.py
+++ b/cli/src/semgrep/commands/ci.py
@@ -650,6 +650,7 @@ def ci(
                 dependency_parser_errors,
                 _executed_rule_count,
                 _missed_rule_count,
+                all_subprojects,
             ) = semgrep.run_scan.run_scan(**run_scan_args)
         except SemgrepError as e:
             # We place output_handler calls after scan_handler calls
@@ -712,6 +713,7 @@ def ci(
                     # Usage limits currently only consider last 30 days.
                     _executed_rule_count,
                     _missed_rule_count,
+                    _historical_all_subprojects,
                 ) = semgrep.run_scan.run_scan(
                     **run_scan_args,
                     historical_secrets=True,
@@ -845,6 +847,7 @@ def ci(
                     metadata.commit_datetime,
                     dependencies,
                     dependency_parser_errors,
+                    all_subprojects,
                     contributions,
                     engine_type,
                     progress_bar,
diff --git a/cli/src/semgrep/commands/scan.py b/cli/src/semgrep/commands/scan.py
index 05fffe1255e9..469f8efb1d23 100644
--- a/cli/src/semgrep/commands/scan.py
+++ b/cli/src/semgrep/commands/scan.py
@@ -509,13 +509,6 @@ def scan_options(func: Callable) -> Callable:
     "run_secrets_flag",
     is_flag=True,
 )
-@optgroup.group("Osemgrep migration options")
-@optgroup.option(
-    "--use-osemgrep-sarif",
-    "use_osemgrep_sarif",
-    is_flag=True,
-    default=False,
-)
 @scan_options
 @handle_command_errors
 def scan(
@@ -578,7 +571,6 @@ def scan(
     trace: bool,
     trace_endpoint: Optional[str],
     use_git_ignore: bool,
-    use_osemgrep_sarif: bool,
     validate: bool,
     verbose: bool,
     version: bool,
@@ -687,10 +679,6 @@ def scan(
             semgrep.config_resolver.adjust_for_docker()
             targets = (os.curdir,)
 
-        use_osemgrep_to_format: Set[OutputFormat] = set()
-        if use_osemgrep_sarif:
-            use_osemgrep_to_format.add(OutputFormat.SARIF)
-
         outputs = collect_additional_outputs(
             outputs_text=outputs_text,
             outputs_emacs=outputs_emacs,
@@ -714,7 +702,6 @@ def scan(
             dataflow_traces=dataflow_traces,
             max_log_list_entries=max_log_list_entries,
             # those are not set in ci.py as they are scan-specific flags
-            use_osemgrep_to_format=use_osemgrep_to_format,
             error_on_findings=error_on_findings,
             strict=strict,
         )
@@ -821,6 +808,7 @@ def scan(
                         _dependency_parser_errors,
                         executed_rule_count,
                         missed_rule_count,
+                        _all_subprojects,
                     ) = semgrep.run_scan.run_scan(
                         diff_depth=diff_depth,
                         dump_command_for_core=dump_command_for_core,
diff --git a/cli/src/semgrep/formatter/osemgrep_sarif.py b/cli/src/semgrep/formatter/osemgrep_sarif.py
deleted file mode 100644
index 12e48c3011c8..000000000000
--- a/cli/src/semgrep/formatter/osemgrep_sarif.py
+++ /dev/null
@@ -1,190 +0,0 @@
-import contextlib
-import difflib
-import json
-import tempfile
-import timeit
-from typing import Any
-from typing import Iterable
-from typing import Mapping
-from typing import Optional
-from typing import Sequence
-
-import semgrep.formatter.base as base
-import semgrep.rpc_call
-import semgrep.semgrep_interfaces.semgrep_output_v1 as out
-from semgrep.error import SemgrepError
-from semgrep.formatter.sarif import SarifFormatter
-from semgrep.rule import Rule
-from semgrep.rule_match import RuleMatch
-from semgrep.semgrep_interfaces.semgrep_metrics import OsemgrepFormatOutput
-from semgrep.state import get_state
-from semgrep.verbose_logging import getLogger
-
-
-logger = getLogger(__name__)
-
-
-# Rules may be ordered differently in python and ocaml, but it
-# shouldn't matter to the user, so we will sort the rules by rule id
-# just for validating purposes.
-def normalize_sarif_findings(findings: Mapping, mode: str) -> Mapping:
-    try:
-        for run in findings["runs"]:
-            sorted_rules = sorted(
-                run["tool"]["driver"]["rules"], key=lambda rule: rule["id"]
-            )
-            run["tool"]["driver"]["rules"] = sorted_rules
-    except KeyError as e:
-        logger.verbose(f"Invalid SARIF format ({mode}): key not found {e}")
-    return findings
-
-
-class OsemgrepSarifFormatter(base.BaseFormatter):
-    def _osemgrep_format(
-        self,
-        rules: Iterable[Rule],
-        rule_matches: Iterable[RuleMatch],
-        semgrep_structured_errors: Sequence[SemgrepError],
-        cli_output_extra: out.CliOutputExtra,
-        extra: Mapping[str, Any],
-        ctx: out.FormatContext,
-    ) -> Optional[out.SarifFormatReturn]:
-        exit_stack = contextlib.ExitStack()
-        with exit_stack:
-            rule_file = exit_stack.enter_context(
-                tempfile.NamedTemporaryFile("w+", suffix=".json")
-            )
-            rule_file_contents = json.dumps(
-                {"rules": [rule._raw for rule in rules]}, indent=2, sort_keys=True
-            )
-            rule_file.write(rule_file_contents)
-            rule_file.flush()
-            rules_path = out.Fpath(rule_file.name)
-
-            """
-            Exclude Semgrep notice for users who
-            1. log in
-            2. use pro engine
-            3. are not using registry
-            """
-            is_pro = (
-                cli_output_extra.engine_requested
-                and cli_output_extra.engine_requested == out.EngineKind(out.PRO_())
-            )
-            hide_nudge = ctx.is_logged_in or is_pro or not ctx.is_using_registry
-
-            engine_label = "PRO" if is_pro else "OSS"
-
-            show_dataflow_traces = extra["dataflow_traces"]
-
-            # Sort according to RuleMatch.get_ordering_key
-            sorted_findings = sorted(rule_matches)
-            cli_matches = [
-                base.rule_match_to_CliMatch(rule_match)
-                for rule_match in sorted_findings
-            ]
-            cli_errors = [e.to_CliError() for e in semgrep_structured_errors]
-
-            rpc_params = out.SarifFormatParams(
-                hide_nudge,
-                engine_label,
-                rules_path,
-                cli_matches,
-                cli_errors,
-                show_dataflow_traces,
-            )
-            formatted_output = semgrep.rpc_call.sarif_format(rpc_params)
-            if formatted_output:
-                return formatted_output.value
-        return None
-
-    def keep_ignores(self) -> bool:
-        # SARIF output includes ignored findings, but labels them as suppressed.
-        # https://docs.oasis-open.org/sarif/sarif/v2.1.0/csprd01/sarif-v2.1.0-csprd01.html#_Toc10541099
-        return True
-
-    def format(
-        self,
-        rules: Iterable[Rule],
-        rule_matches: Iterable[RuleMatch],
-        semgrep_structured_errors: Sequence[SemgrepError],
-        cli_output_extra: out.CliOutputExtra,
-        extra: Mapping[str, Any],
-        ctx: out.FormatContext,
-    ) -> str:
-        rule_list = list(rules)
-        rule_match_list = list(rule_matches)
-        error_list = list(semgrep_structured_errors)
-        rpc_start = timeit.default_timer()
-        rpc_result = self._osemgrep_format(
-            rule_list, rule_match_list, error_list, cli_output_extra, extra, ctx
-        )
-        rpc_elapse = timeit.default_timer() - rpc_start
-
-        pysemgrep_formatter = SarifFormatter()
-        py_start = timeit.default_timer()
-        py_output = pysemgrep_formatter.format(
-            rule_list,
-            rule_match_list,
-            error_list,
-            cli_output_extra,
-            extra,
-            ctx,
-        )
-        py_elapse = timeit.default_timer() - py_start
-
-        succeeded = False
-        is_match = None
-        validate_elapse = None
-        o_elapse = None
-        if rpc_result is not None:
-            succeeded = True
-            o_elapse = rpc_result.format_time_seconds
-
-            # Validate results and time it to make sure it's not expensive.
-            validate_start = timeit.default_timer()
-            o_output = rpc_result.output
-            o_json = normalize_sarif_findings(json.loads(o_output), "osemgrep")
-            py_json = normalize_sarif_findings(json.loads(py_output), "pysemgrep")
-            is_match = o_json == py_json
-            if not is_match:
-                # Log the difference when there's a mismatch.
-                #
-                # Be more verbose with --debug. Helpful for local testing.
-                #
-                # Otherwise cap the log to 10 lines with --verbose,
-                # which would be helpful for debugging when things go wrong in CI.
-                o_lines = json.dumps(o_json, sort_keys=True, indent=4).split("\n")
-                py_lines = json.dumps(py_json, sort_keys=True, indent=4).split("\n")
-                if get_state().terminal.is_debug:
-                    diffs = difflib.unified_diff(o_lines, py_lines, n=10)
-                    logger.debug("diff osemgrep vs pysemgrep:\n" + "\n".join(diffs))
-                else:
-                    diffs_list = list(difflib.unified_diff(o_lines, py_lines, n=1))
-                    max_n_lines = 10
-                    lines = "\n".join(diffs_list[:max_n_lines])
-                    if len(diffs_list) > max_n_lines:
-                        logger.verbose(
-                            f"partial diff osemgrep vs pysemgrep (use --debug to see full diff):\n{lines}"
-                        )
-                    else:
-                        logger.verbose(f"diff osemgrep vs pysemgrep:\n{lines}")
-            validate_elapse = timeit.default_timer() - validate_start
-
-        # Update metrics so we can keep track of how well the migration is going.
-        format_metrics = OsemgrepFormatOutput()
-        format_metrics.format = "SARIF"
-        format_metrics.succeeded = succeeded
-        format_metrics.is_match = is_match
-        format_metrics.osemgrep_rpc_response_time_seconds = rpc_elapse
-        format_metrics.osemgrep_format_time_seconds = o_elapse
-        format_metrics.pysemgrep_format_time_seconds = py_elapse
-        format_metrics.validation_time_seconds = validate_elapse
-        get_state().metrics.add_osemgrep_format_output_metrics(format_metrics)
-
-        if succeeded and is_match:
-            return o_output
-        logger.verbose(
-            "Osemgrep vs Pysemgrep SARIF output mismatch. Falling back to python output."
-        )
-        return py_output
diff --git a/cli/src/semgrep/formatter/sarif.py b/cli/src/semgrep/formatter/sarif.py
index adc66280a70c..499312dd2b7e 100644
--- a/cli/src/semgrep/formatter/sarif.py
+++ b/cli/src/semgrep/formatter/sarif.py
@@ -1,404 +1,81 @@
+import contextlib
 import json
+import tempfile
 from typing import Any
-from typing import Dict
 from typing import Iterable
 from typing import Mapping
 from typing import Optional
 from typing import Sequence
 
 import semgrep.formatter.base as base
+import semgrep.rpc_call
 import semgrep.semgrep_interfaces.semgrep_output_v1 as out
-from semgrep import __VERSION__
-from semgrep.error import error_type_string
 from semgrep.error import SemgrepError
 from semgrep.rule import Rule
 from semgrep.rule_match import RuleMatch
-from semgrep.verbose_logging import getLogger
-
-logger = getLogger(__name__)
 
 
 class SarifFormatter(base.BaseFormatter):
-    @staticmethod
-    def _taint_source_to_thread_flow_location_sarif(rule_match: RuleMatch) -> Any:
-        dataflow_trace = rule_match.dataflow_trace
-        if not dataflow_trace:
-            return None
-        taint_source = dataflow_trace.taint_source
-        if not taint_source:
-            return None
-        if isinstance(taint_source.value, out.CliCall):
-            logger.error(
-                "Emitting SARIF output for unsupported dataflow trace (source is a call)"
-            )
-            return None
-        elif isinstance(taint_source.value, out.CliLoc):
-            taint_source_value = taint_source.value.value.value
-            location = taint_source_value[0]
-            content = "".join(taint_source_value[1]).strip()
-            source_message_text = f"Source: '{content}' @ '{str(location.path.value)}:{str(location.start.line)}'"
-
-            taint_source_location_sarif = {
-                "location": {
-                    "message": {"text": source_message_text},
-                    "physicalLocation": {
-                        "artifactLocation": {"uri": str(rule_match.path)},
-                        "region": {
-                            "startLine": location.start.line,
-                            "startColumn": location.start.col,
-                            "endLine": location.end.line,
-                            "endColumn": location.end.col,
-                            "snippet": {"text": content},
-                            "message": {"text": source_message_text},
-                        },
-                    },
-                },
-                "nestingLevel": 0,
-            }
-            return taint_source_location_sarif
-
-    @staticmethod
-    def _intermediate_vars_to_thread_flow_location_sarif(rule_match: RuleMatch) -> Any:
-        dataflow_trace = rule_match.dataflow_trace
-        if not dataflow_trace:
-            return None
-        intermediate_vars = dataflow_trace.intermediate_vars
-        if not intermediate_vars:
-            return None
-        intermediate_var_locations = []
-        for intermediate_var in intermediate_vars:
-            location = intermediate_var.location
-            content = "".join(intermediate_var.content).strip()
-            propagation_message_text = f"Propagator : '{content}' @ '{str(location.path.value)}:{str(location.start.line)}'"
-
-            intermediate_vars_location_sarif = {
-                "location": {
-                    "message": {"text": propagation_message_text},
-                    "physicalLocation": {
-                        "artifactLocation": {"uri": str(rule_match.path)},
-                        "region": {
-                            "startLine": location.start.line,
-                            "startColumn": location.start.col,
-                            "endLine": location.end.line,
-                            "endColumn": location.end.col,
-                            "snippet": {"text": content},
-                            "message": {"text": propagation_message_text},
-                        },
-                    },
-                },
-                "nestingLevel": 0,
-            }
-            intermediate_var_locations.append(intermediate_vars_location_sarif)
-        return intermediate_var_locations
-
-    @staticmethod
-    def _sink_to_thread_flow_location_sarif(rule_match: RuleMatch) -> Any:
-        content = "".join(rule_match.get_lines()).strip()
-        sink_message_text = (
-            f"Sink: '{content}' @ '{str(rule_match.path)}:{str(rule_match.start.line)}'"
-        )
-
-        sink_location_sarif = {
-            "location": {
-                "message": {"text": sink_message_text},
-                "physicalLocation": {
-                    "artifactLocation": {"uri": str(rule_match.path)},
-                    "region": {
-                        "startLine": rule_match.start.line,
-                        "startColumn": rule_match.start.col,
-                        "endLine": rule_match.end.line,
-                        "endColumn": rule_match.end.col,
-                        "snippet": {"text": "".join(rule_match.lines).rstrip()},
-                        "message": {"text": sink_message_text},
-                    },
-                },
-            },
-            "nestingLevel": 1,
-        }
-        return sink_location_sarif
-
-    @staticmethod
-    def _dataflow_trace_to_thread_flows_sarif(rule_match: RuleMatch) -> Any:
-        thread_flows = []
-        locations = []
-
-        dataflow_trace = rule_match.dataflow_trace
-        if not dataflow_trace:
-            return None
-        taint_source = dataflow_trace.taint_source
-        # TODO: deal with taint sink
-        intermediate_vars = dataflow_trace.intermediate_vars
+    def keep_ignores(self) -> bool:
+        return True
 
-        if taint_source:
-            locations.append(
-                SarifFormatter._taint_source_to_thread_flow_location_sarif(rule_match)
+    def _osemgrep_format(
+        self,
+        rules: Iterable[Rule],
+        rule_matches: Iterable[RuleMatch],
+        semgrep_structured_errors: Sequence[SemgrepError],
+        cli_output_extra: out.CliOutputExtra,
+        extra: Mapping[str, Any],
+        ctx: out.FormatContext,
+    ) -> Optional[out.SarifFormatReturn]:
+        exit_stack = contextlib.ExitStack()
+        with exit_stack:
+            rule_file = exit_stack.enter_context(
+                tempfile.NamedTemporaryFile("w+", suffix=".json")
             )
-
-        if intermediate_vars:
-            intermediate_var_locations = (
-                SarifFormatter._intermediate_vars_to_thread_flow_location_sarif(
-                    rule_match
-                )
+            rule_file_contents = json.dumps(
+                {"rules": [rule._raw for rule in rules]}, indent=2, sort_keys=True
             )
-            if intermediate_var_locations:
-                for intermediate_var_location in intermediate_var_locations:
-                    locations.append(intermediate_var_location)
-
-        locations.append(SarifFormatter._sink_to_thread_flow_location_sarif(rule_match))
-
-        thread_flows.append({"locations": locations})
-        return thread_flows
-
-    @staticmethod
-    def _dataflow_trace_to_codeflow_sarif(
-        rule_match: RuleMatch,
-    ) -> Optional[Mapping[str, Any]]:
-        dataflow_trace = rule_match.dataflow_trace
-        if not dataflow_trace:
-            return None
-        taint_source = dataflow_trace.taint_source
-        if not taint_source:
-            return None
-
-        # TODO: handle rule_match.taint_sink
-        if isinstance(taint_source.value, out.CliCall):
-            logger.error(
-                "Emitting SARIF output for unsupported dataflow trace (source is a call)"
+            rule_file.write(rule_file_contents)
+            rule_file.flush()
+            rules_path = out.Fpath(rule_file.name)
+
+            """
+            Exclude Semgrep notice for users who
+            1. log in
+            2. use pro engine
+            3. are not using registry
+            """
+            is_pro = (
+                cli_output_extra.engine_requested
+                and cli_output_extra.engine_requested == out.EngineKind(out.PRO_())
             )
-            return None
-        elif isinstance(taint_source.value, out.CliLoc):
-            location = taint_source.value.value.value[0]
-            code_flow_message = f"Untrusted dataflow from {str(location.path.value)}:{str(location.start.line)} to {str(rule_match.path)}:{str(rule_match.start.line)}"
-            code_flow_sarif = {
-                "message": {"text": code_flow_message},
-            }
-            thread_flows = SarifFormatter._dataflow_trace_to_thread_flows_sarif(
-                rule_match
+            hide_nudge = ctx.is_logged_in or is_pro or not ctx.is_using_registry
+
+            engine_label = "PRO" if is_pro else "OSS"
+
+            show_dataflow_traces = extra["dataflow_traces"]
+
+            # Sort according to RuleMatch.get_ordering_key
+            sorted_findings = sorted(rule_matches)
+            cli_matches = [
+                base.rule_match_to_CliMatch(rule_match)
+                for rule_match in sorted_findings
+            ]
+            cli_errors = [e.to_CliError() for e in semgrep_structured_errors]
+
+            rpc_params = out.SarifFormatParams(
+                hide_nudge,
+                engine_label,
+                rules_path,
+                cli_matches,
+                cli_errors,
+                show_dataflow_traces,
             )
-            if thread_flows:
-                code_flow_sarif["threadFlows"] = thread_flows
-
-            return code_flow_sarif
-
-    @staticmethod
-    def _rule_match_to_sarif(
-        rule_match: RuleMatch, dataflow_traces: bool
-    ) -> Mapping[str, Any]:
-        rule_match_sarif: Dict[str, Any] = {
-            "ruleId": rule_match.rule_id,
-            "message": {"text": rule_match.message},
-            "locations": [
-                {
-                    "physicalLocation": {
-                        "artifactLocation": {
-                            "uri": str(rule_match.path),
-                            "uriBaseId": "%SRCROOT%",
-                        },
-                        "region": {
-                            "snippet": {"text": "".join(rule_match.lines).rstrip()},
-                            "startLine": rule_match.start.line,
-                            "startColumn": rule_match.start.col,
-                            "endLine": rule_match.end.line,
-                            "endColumn": rule_match.end.col,
-                        },
-                    }
-                }
-            ],
-            "fingerprints": {"matchBasedId/v1": rule_match.match_based_id},
-            "properties": {},
-        }
-
-        if dataflow_traces and rule_match.dataflow_trace:
-            code_flows = SarifFormatter._dataflow_trace_to_codeflow_sarif(rule_match)
-            if code_flows:
-                rule_match_sarif["codeFlows"] = [code_flows]
-
-        if rule_match.is_ignored:
-            rule_match_sarif["suppressions"] = [{"kind": "inSource"}]
-
-        fix = SarifFormatter._rule_match_to_sarif_fix(rule_match)
-
-        if fix is not None:
-            rule_match_sarif["fixes"] = [fix]
-
-        if rule_match.exposure_type:
-            rule_match_sarif["properties"]["exposure"] = rule_match.exposure_type
-
-        return rule_match_sarif
-
-    @staticmethod
-    def _rule_match_to_sarif_fix(rule_match: RuleMatch) -> Optional[Mapping[str, Any]]:
-        # if rule_match.extra.get("dependency_matches"):
-        fixed_lines = rule_match.extra.get("fixed_lines")
-
-        description = "Semgrep rule suggested fix"
-        if not fixed_lines:
-            return None
-        description_text = f"{rule_match.message}\n Autofix: {description}"
-        fix_sarif = {
-            "description": {"text": description_text},
-            "artifactChanges": [
-                {
-                    "artifactLocation": {"uri": str(rule_match.path)},
-                    "replacements": [
-                        {
-                            "deletedRegion": {
-                                "startLine": rule_match.start.line,
-                                "startColumn": rule_match.start.col,
-                                "endLine": rule_match.end.line,
-                                "endColumn": rule_match.end.col,
-                            },
-                            "insertedContent": {"text": "\n".join(fixed_lines)},
-                        }
-                    ],
-                }
-            ],
-        }
-        return fix_sarif
-
-    @staticmethod
-    def _rule_to_sarif(rule: Rule, hide_nudge: bool) -> Mapping[str, Any]:
-        severity = SarifFormatter._rule_to_sarif_severity(rule)
-        tags = SarifFormatter._rule_to_sarif_tags(rule)
-
-        rule_url = rule.metadata.get("source")
-        rule_short_description = rule.metadata.get("shortDescription")
-        rule_help_text = rule.metadata.get("help") or rule.message or ""
-        rule_short_description = rule.metadata.get("shortDescription")
-        security_severity = rule.metadata.get("security-severity")
-
-        nudge_base = "💎 Enable cross-file analysis and Pro rules for free at"
-        nudge_url = "sg.run/pro"
-        nudge_plaintext = f"{nudge_base} {nudge_url}"
-        nudge_md = f"#### {nudge_base} <a href='https://{nudge_url}'>{nudge_url}</a>"
-
-        properties = {
-            "precision": "very-high",
-            "tags": tags,
-        }
-
-        if security_severity is not None:
-            properties["security-severity"] = security_severity
-
-        rule_json = {
-            "id": rule.id,
-            "name": rule.id,
-            "shortDescription": {"text": f"Semgrep Finding: {rule.id}"},
-            "fullDescription": {"text": rule.message},
-            "defaultConfiguration": {"level": severity},
-            "properties": properties,
-        }
-
-        if rule_short_description:
-            rule_json["shortDescription"] = {"text": rule_short_description}
-
-        if rule_url is not None:
-            rule_json["helpUri"] = rule_url
-
-        semgrep_reference = f"[Semgrep Rule]({rule_url})" if rule_url else None
-        references = [semgrep_reference] if semgrep_reference else []
-        other_references = rule.metadata.get("references") or []
-        # TODO: Handle cases which aren't URLs in custom rules, wont be a problem semgrep-rules.
-        references.extend(
-            [f"[{r}]({r})" for r in other_references]
-            if isinstance(other_references, list)
-            else [f"[{other_references}]({other_references})"]
-        )
-        references_joined = "".join(f" - {ref}\n" for ref in references)
-        references_markdown = (
-            ""
-            if not references_joined
-            else f"\n\n<b>References:</b>\n{references_joined}"
-        )
-        text_suffix = "" if hide_nudge else f"\n{nudge_plaintext}"
-        markdown_interstitial = "" if hide_nudge else f"\n\n{nudge_md}"
-        rule_json["help"] = {
-            "text": f"{rule_help_text}{text_suffix}",
-            "markdown": f"{rule_help_text}{markdown_interstitial}{references_markdown}",
-        }
-
-        return rule_json
-
-    @staticmethod
-    def _rule_to_sarif_severity(rule: Rule) -> str:
-        """
-        SARIF v2.1.0-compliant severity string.
-
-        See https://github.com/oasis-tcs/sarif-spec/blob/a6473580/Schemata/sarif-schema-2.1.0.json#L1566
-        """
-        mapping = {
-            out.Info(): "note",
-            out.Low(): "note",
-            out.Warning(): "warning",
-            out.Medium(): "warning",
-            out.Error(): "error",
-            out.High(): "error",
-            out.Critical(): "error",
-        }
-        return mapping[rule.severity.value]
-
-    @staticmethod
-    def _rule_to_sarif_tags(rule: Rule) -> Sequence[str]:
-        """
-        Tags to display on SARIF-compliant UIs, such as GitHub security scans.
-        """
-        result = []
-        if "cwe" in rule.metadata:
-            cwe = rule.metadata["cwe"]
-            result.extend(cwe if isinstance(cwe, list) else [cwe])
-            result.append("security")
-        if "owasp" in rule.metadata:
-            owasp = rule.metadata["owasp"]
-            result.extend(
-                [f"OWASP-{o}" for o in owasp]
-                if isinstance(owasp, list)
-                else [f"OWASP-{owasp}"]
-            )
-        if rule.metadata.get("confidence"):
-            confidence = rule.metadata["confidence"]
-            result.append(f"{confidence} CONFIDENCE")
-        if (
-            "semgrep.policy" in rule.metadata
-            and "slug" in rule.metadata["semgrep.policy"]
-        ):
-            # https://github.com/returntocorp/semgrep-app/blob/8d2e6187b7daa2b20c49839a4fcb67e560202aa8/frontend/src/pages/ruleBoard/constants/constants.tsx#L74
-            # this should be "rule-board-audit", "rule-board-block", or "rule-board-pr-comments"
-            slug = rule.metadata["semgrep.policy"]["slug"]
-            result.append(slug)
-
-        for tags in rule.metadata.get("tags", []):
-            result.append(tags)
-
-        return sorted(set(result))
-
-    @staticmethod
-    def _semgrep_error_to_sarif_notification(error: SemgrepError) -> Mapping[str, Any]:
-        cli_error = error.to_CliError()
-        descriptor: str = error_type_string(cli_error.type_)
-
-        error_to_sarif_level = {
-            out.Error_(): "error",
-            out.Warning_(): "warning",
-            out.Info_(): "note",
-        }
-        level = error_to_sarif_level[error.level.value]
-
-        message = cli_error.message
-        if message is None:
-            message = cli_error.long_msg
-        if message is None:
-            message = cli_error.short_msg or ""
-
-        return {
-            "descriptor": {"id": descriptor},
-            "message": {"text": message},
-            "level": level,
-        }
-
-    def keep_ignores(self) -> bool:
-        # SARIF output includes ignored findings, but labels them as suppressed.
-        # https://docs.oasis-open.org/sarif/sarif/v2.1.0/csprd01/sarif-v2.1.0-csprd01.html#_Toc10541099
-        return True
+            formatted_output = semgrep.rpc_call.sarif_format(rpc_params)
+            if formatted_output:
+                return formatted_output.value
+        return None
 
     def format(
         self,
@@ -409,64 +86,20 @@ def format(
         extra: Mapping[str, Any],
         ctx: out.FormatContext,
     ) -> str:
-        """
-        Format matches in SARIF v2.1.0 formatted JSON.
-
-        - Written based on:
-            https://help.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/about-sarif-support-for-code-scanning
-        - Which links to this schema:
-            https://github.com/oasis-tcs/sarif-spec/blob/master/Schemata/sarif-schema-2.1.0.json
-        - Full specification is at:
-            https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html
-        """
-        sorted_findings = sorted(rule_matches)
-        engine_label = (
-            cli_output_extra.engine_requested.value.to_json()
-            if cli_output_extra.engine_requested
-            else "OSS"
+        # TODO: use regular OutputFormat RPC but SARIF needs a few
+        #   more things such as the rules so we use a different RPC for now
+        # output = base.to_CliOutput(
+        #     rule_matches, semgrep_structured_errors, cli_output_extra
+        # )
+        # return semgrep.rpc_call.format(out.OutputFormat(out.Sarif()), ctx, output)
+        rule_list = list(rules)
+        rule_match_list = list(rule_matches)
+        error_list = list(semgrep_structured_errors)
+        rpc_result = self._osemgrep_format(
+            rule_list, rule_match_list, error_list, cli_output_extra, extra, ctx
         )
-        """
-        Exclude Semgrep notice for users who
-        1. log in
-        2. use pro engine
-        3. are not using registry
-        """
-        is_pro = (
-            cli_output_extra.engine_requested
-            and cli_output_extra.engine_requested == out.EngineKind(out.PRO_())
-        )
-        hide_nudge = ctx.is_logged_in or is_pro or not ctx.is_using_registry
-
-        output_dict = {
-            "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
-            "version": "2.1.0",
-            "runs": [
-                {
-                    "tool": {
-                        "driver": {
-                            "name": f"Semgrep {engine_label}",
-                            "semanticVersion": __VERSION__,
-                            "rules": [
-                                self._rule_to_sarif(rule, hide_nudge) for rule in rules
-                            ],
-                        }
-                    },
-                    "results": [
-                        self._rule_match_to_sarif(rule_match, extra["dataflow_traces"])
-                        for rule_match in sorted_findings
-                    ],
-                    "invocations": [
-                        {
-                            "executionSuccessful": True,
-                            "toolExecutionNotifications": [
-                                self._semgrep_error_to_sarif_notification(error)
-                                for error in semgrep_structured_errors
-                            ],
-                        }
-                    ],
-                },
-            ],
-        }
-
-        # Sort keys for predictable output. This helps with snapshot tests, etc.
-        return json.dumps(output_dict, sort_keys=True, indent=2)
+        if rpc_result is not None:
+            o_output = rpc_result.output
+            return o_output
+        else:
+            return "<ERROR: no SARIF output>"
diff --git a/cli/src/semgrep/metrics.py b/cli/src/semgrep/metrics.py
index 30daa4f00ea6..2991956004ab 100644
--- a/cli/src/semgrep/metrics.py
+++ b/cli/src/semgrep/metrics.py
@@ -45,8 +45,6 @@
 from semgrep.semgrep_interfaces.semgrep_metrics import Interfile
 from semgrep.semgrep_interfaces.semgrep_metrics import Interprocedural
 from semgrep.semgrep_interfaces.semgrep_metrics import Intraprocedural
-from semgrep.semgrep_interfaces.semgrep_metrics import OsemgrepFormatOutput
-from semgrep.semgrep_interfaces.semgrep_metrics import OsemgrepMetrics
 from semgrep.semgrep_interfaces.semgrep_metrics import ParseStat
 from semgrep.semgrep_interfaces.semgrep_metrics import Payload
 from semgrep.semgrep_interfaces.semgrep_metrics import Performance
@@ -376,12 +374,6 @@ def add_errors(self, errors: List[SemgrepError]) -> None:
             met.Error(error_type_string(e.type_())) for e in errors
         ]
 
-    @suppress_errors
-    def add_osemgrep_format_output_metrics(self, o: OsemgrepFormatOutput) -> None:
-        if self.payload.osemgrep is None:
-            self.payload.osemgrep = OsemgrepMetrics()
-        self.payload.osemgrep.format_output = o
-
     @suppress_errors
     def add_profiling(self, profiler: ProfileManager) -> None:
         self.payload.performance.profilingTimes = [
diff --git a/cli/src/semgrep/output.py b/cli/src/semgrep/output.py
index 2e2702f60a4f..1250836027b2 100644
--- a/cli/src/semgrep/output.py
+++ b/cli/src/semgrep/output.py
@@ -38,7 +38,6 @@
 from semgrep.formatter.gitlab_secrets import GitlabSecretsFormatter
 from semgrep.formatter.json import JsonFormatter
 from semgrep.formatter.junit_xml import JunitXmlFormatter
-from semgrep.formatter.osemgrep_sarif import OsemgrepSarifFormatter
 from semgrep.formatter.sarif import SarifFormatter
 from semgrep.formatter.text import TextFormatter
 from semgrep.formatter.vim import VimFormatter
@@ -71,12 +70,6 @@
     OutputFormat.VIM: VimFormatter,
 }
 
-
-OSEMGREP_FORMATTERS: Mapping[OutputFormat, Type[base.BaseFormatter]] = {
-    OutputFormat.SARIF: OsemgrepSarifFormatter,
-}
-
-
 # Experiment and Inventory are not below on purpose
 DEFAULT_SHOWN_SEVERITIES: Collection[out.MatchSeverity] = frozenset(
     {
@@ -164,7 +157,6 @@ class OutputSettings(NamedTuple):
     output_time: bool = False
     timeout_threshold: int = 0
     dataflow_traces: bool = False
-    use_osemgrep_to_format: Optional[Set[OutputFormat]] = None
     max_log_list_entries: int = 0
 
     def normalize(self) -> NormalizedOutputSettings:
@@ -238,25 +230,10 @@ def __init__(
 
         for output_destination, output_format in self.settings.get_outputs():
             formatter: Optional[base.BaseFormatter] = None
-            # If configured to use osemgrep to format the output, use the osemgrep formatter.
-            if (
-                output_settings.use_osemgrep_to_format
-                and output_format in output_settings.use_osemgrep_to_format
-            ):
-                if output_format in OSEMGREP_FORMATTERS:
-                    formatter = OSEMGREP_FORMATTERS[output_format]()
-                else:
-                    logger.verbose(
-                        f"Osemgrep formatter for {output_format} is not supported yet. "
-                        "Falling back to pysemgrep formatter."
-                    )
-
-            # If the formatter is not yet supported, fallback to the pysemgrep formatter
             if formatter is None:
                 formatter_type = FORMATTERS.get(output_format)
                 if formatter_type is not None:
                     formatter = formatter_type()
-
             if formatter is None:
                 raise RuntimeError(f"Invalid output format: {output_format}")
 
@@ -416,7 +393,6 @@ def output(
         is_ci_invocation: bool = False,
         executed_rule_count: int = 0,
         missed_rule_count: int = 0,
-        use_osemgrep_format_output: bool = False,
     ) -> None:
         state = get_state()
         self.has_output = True
diff --git a/cli/src/semgrep/resolve_dependency_source.py b/cli/src/semgrep/resolve_dependency_source.py
new file mode 100644
index 000000000000..31982daaf9fa
--- /dev/null
+++ b/cli/src/semgrep/resolve_dependency_source.py
@@ -0,0 +1,285 @@
+from pathlib import Path
+from typing import Dict
+from typing import List
+from typing import Optional
+from typing import Sequence
+from typing import Set
+from typing import Tuple
+from typing import Union
+
+import semgrep.semgrep_interfaces.semgrep_output_v1 as out
+from semdep.parsers.cargo import parse_cargo
+from semdep.parsers.composer import parse_composer_lock
+from semdep.parsers.gem import parse_gemfile
+from semdep.parsers.go_mod import parse_go_mod
+from semdep.parsers.gradle import parse_gradle
+from semdep.parsers.mix import parse_mix
+from semdep.parsers.package_lock import parse_package_lock
+from semdep.parsers.packages_lock_c_sharp import (
+    parse_packages_lock as parse_packages_lock_c_sharp,
+)
+from semdep.parsers.pipfile import parse_pipfile
+from semdep.parsers.pnpm import parse_pnpm
+from semdep.parsers.poetry import parse_poetry
+from semdep.parsers.pom_tree import parse_pom_tree
+from semdep.parsers.pubspec_lock import parse_pubspec_lock
+from semdep.parsers.requirements import parse_requirements
+from semdep.parsers.swiftpm import parse_package_resolved
+from semdep.parsers.util import SemgrepParser
+from semdep.parsers.util import to_parser
+from semdep.parsers.yarn import parse_yarn
+from semgrep.error import DependencyResolutionError
+from semgrep.rpc_call import resolve_dependencies
+from semgrep.semgrep_interfaces.semgrep_output_v1 import CargoParser
+from semgrep.semgrep_interfaces.semgrep_output_v1 import DependencyParserError
+from semgrep.semgrep_interfaces.semgrep_output_v1 import Ecosystem
+from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
+from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
+from semgrep.subproject import DependencySource
+from semgrep.subproject import LockfileOnlyDependencySource
+from semgrep.subproject import ManifestLockfileDependencySource
+from semgrep.subproject import ManifestOnlyDependencySource
+from semgrep.subproject import MultiLockfileDependencySource
+from semgrep.subproject import ResolutionMethod
+from semgrep.verbose_logging import getLogger
+
+logger = getLogger(__name__)
+
+
+# argument order is lockfile path, manifest path
+PARSERS_BY_LOCKFILE_KIND: Dict[out.LockfileKind, SemgrepParser] = {
+    out.LockfileKind(out.PipfileLock()): parse_pipfile,
+    out.LockfileKind(out.PipRequirementsTxt()): parse_requirements,
+    out.LockfileKind(out.PoetryLock()): parse_poetry,
+    out.LockfileKind(out.NpmPackageLockJson()): parse_package_lock,
+    out.LockfileKind(out.YarnLock()): parse_yarn,
+    out.LockfileKind(out.PnpmLock()): parse_pnpm,
+    out.LockfileKind(out.GemfileLock()): parse_gemfile,
+    out.LockfileKind(out.ComposerLock()): parse_composer_lock,
+    out.LockfileKind(out.GoMod_()): parse_go_mod,
+    out.LockfileKind(out.CargoLock()): to_parser(
+        parse_cargo, ScaParserName(CargoParser())
+    ),
+    out.LockfileKind(out.MavenDepTree()): parse_pom_tree,
+    out.LockfileKind(out.GradleLockfile()): parse_gradle,
+    out.LockfileKind(out.NugetPackagesLockJson()): parse_packages_lock_c_sharp,
+    out.LockfileKind(out.PubspecLock()): parse_pubspec_lock,
+    out.LockfileKind(out.SwiftPackageResolved()): parse_package_resolved,
+    out.LockfileKind(out.MixLock()): parse_mix,
+}
+
+ECOSYSTEM_BY_LOCKFILE_KIND: Dict[out.LockfileKind, Ecosystem] = {
+    out.LockfileKind(out.PipfileLock()): Ecosystem(out.Pypi()),
+    out.LockfileKind(out.PipRequirementsTxt()): Ecosystem(out.Pypi()),
+    out.LockfileKind(out.PoetryLock()): Ecosystem(out.Pypi()),
+    out.LockfileKind(out.NpmPackageLockJson()): Ecosystem(out.Npm()),
+    out.LockfileKind(out.YarnLock()): Ecosystem(out.Npm()),
+    out.LockfileKind(out.PnpmLock()): Ecosystem(out.Npm()),
+    out.LockfileKind(out.GemfileLock()): Ecosystem(out.Gem()),
+    out.LockfileKind(out.ComposerLock()): Ecosystem(out.Composer()),
+    out.LockfileKind(out.GoMod_()): Ecosystem(out.Gomod()),
+    out.LockfileKind(out.CargoLock()): Ecosystem(out.Cargo()),
+    out.LockfileKind(out.MavenDepTree()): Ecosystem(out.Maven()),
+    out.LockfileKind(out.GradleLockfile()): Ecosystem(out.Maven()),
+    out.LockfileKind(out.NugetPackagesLockJson()): Ecosystem(out.Nuget()),
+    out.LockfileKind(out.PubspecLock()): Ecosystem(out.Pub()),
+    out.LockfileKind(out.SwiftPackageResolved()): Ecosystem(out.SwiftPM()),
+    out.LockfileKind(out.MixLock()): Ecosystem(out.Mix()),
+}
+
+DEPENDENCY_GRAPH_SUPPORTED_MANIFEST_KINDS = [
+    out.ManifestKind(out.PomXml()),
+    out.ManifestKind(out.BuildGradle()),
+]
+
+DependencyResolutionResult = Tuple[
+    Optional[Tuple[Ecosystem, ResolutionMethod, List[FoundDependency]]],
+    Sequence[Union[DependencyParserError, DependencyResolutionError]],
+    List[Path],
+]
+
+
+def _resolve_dependencies_dynamically(
+    dependency_source: Union[
+        ManifestOnlyDependencySource, ManifestLockfileDependencySource
+    ],
+) -> Tuple[
+    Optional[Tuple[Ecosystem, List[FoundDependency]]],
+    Sequence[DependencyResolutionError],
+    List[Path],
+]:
+    """
+    Handle the RPC call to resolve dependencies dynamically.
+    """
+    response = resolve_dependencies([dependency_source.to_semgrep_output()])
+    if response is None:
+        # we failed to resolve somehow
+        # TODO: handle this and generate an error
+        return None, [], []
+    if len(response) > 1:
+        logger.warning(
+            f"Too many responses from dynamic dependency resolution RPC. Expected 1, got {len(response)}"
+        )
+    result = response[0][1]
+    if isinstance(result.value, out.ResolutionOk):
+        resolved_deps, errors = result.value.value
+        # right now we only support lockfileless for the maven ecosystem, so hardcode that here
+        # TODO: move this ecosystem identification into the ocaml code when we redo the interface there
+        ecosystem = Ecosystem(out.Maven())
+        wrapped_errors = [
+            DependencyResolutionError(
+                type_=e_type,
+                dependency_source_file=Path(dependency_source.manifest.path.value),
+            )
+            for e_type in errors
+        ]
+        return (
+            (ecosystem, resolved_deps),
+            wrapped_errors,
+            [Path(dependency_source.manifest.path.value)],
+        )
+    else:
+        # some error occured in resolution, track it
+        wrapped_errors = [
+            DependencyResolutionError(
+                type_=e_type,
+                dependency_source_file=Path(dependency_source.manifest.path.value),
+            )
+            for e_type in result.value.value
+        ]
+        return (None, wrapped_errors, [])
+
+
+def _handle_manifest_only_source(
+    dep_source: ManifestOnlyDependencySource,
+) -> DependencyResolutionResult:
+    """Handle dependency resolution for manifest-only sources."""
+    resolved_info, new_errors, new_targets = _resolve_dependencies_dynamically(
+        dep_source
+    )
+    if resolved_info is None:
+        return None, new_errors, new_targets
+    new_ecosystem, new_deps = resolved_info
+    return (
+        (new_ecosystem, ResolutionMethod.DYNAMIC, new_deps),
+        new_errors,
+        new_targets,
+    )
+
+
+def _handle_multi_lockfile_source(
+    dep_source: MultiLockfileDependencySource,
+) -> DependencyResolutionResult:
+    """Handle dependency resolution for sources with multiple lockfiles."""
+    all_resolved_deps: List[FoundDependency] = []
+    all_parse_errors: List[Union[DependencyParserError, DependencyResolutionError]] = []
+    all_dep_targets: List[Path] = []
+
+    resolution_methods: Set[ResolutionMethod] = set()
+    ecosystem = None
+
+    for lockfile_source in dep_source.sources:
+        new_resolved_info, new_errors, new_targets = resolve_dependency_source(
+            lockfile_source
+        )
+        if new_resolved_info is not None:
+            ecosystem, resolution_method, new_deps = new_resolved_info
+            resolution_methods.add(resolution_method)
+            all_resolved_deps.extend(new_deps)
+        all_parse_errors.extend(new_errors)
+        all_dep_targets.extend(new_targets)
+
+    if ecosystem is None:
+        return None, all_parse_errors, all_dep_targets
+
+    # if any of the files were resolved using dynamic resolution, mark the whole subproject as resolved that way
+    resolution_method = (
+        ResolutionMethod.DYNAMIC
+        if ResolutionMethod.DYNAMIC in resolution_methods
+        else ResolutionMethod.LOCKFILE_PARSING
+    )
+
+    return (
+        (ecosystem, resolution_method, all_resolved_deps),
+        all_parse_errors,
+        all_dep_targets,
+    )
+
+
+def _handle_lockfile_source(
+    dep_source: Union[LockfileOnlyDependencySource, ManifestLockfileDependencySource],
+    enable_dynamic_resolution: bool,
+    prioritize_dependency_graph_generation: bool,
+) -> DependencyResolutionResult:
+    """Handle dependency resolution for lockfile-based sources."""
+    lockfile_path = Path(dep_source.lockfile.path.value)
+    parser = PARSERS_BY_LOCKFILE_KIND[dep_source.lockfile.kind]
+    ecosystem = ECOSYSTEM_BY_LOCKFILE_KIND[dep_source.lockfile.kind]
+
+    if (
+        enable_dynamic_resolution
+        and prioritize_dependency_graph_generation
+        and isinstance(dep_source, ManifestLockfileDependencySource)
+        and dep_source.manifest.kind in DEPENDENCY_GRAPH_SUPPORTED_MANIFEST_KINDS
+    ):
+        (
+            resolved_info,
+            new_errors,
+            new_targets,
+        ) = _resolve_dependencies_dynamically(dep_source)
+
+        if resolved_info is not None:
+            # TODO: Reimplement this once more robust error handling for lockfileless resolution is implemented
+            new_ecosystem, new_deps = resolved_info
+            return (
+                (new_ecosystem, ResolutionMethod.DYNAMIC, new_deps),
+                new_errors,
+                new_targets,
+            )
+
+    # Parse lockfile (used for both standard parsing and as fallback for failed dynamic resolution)
+    manifest_path = (
+        Path(dep_source.manifest.path.value)
+        if isinstance(dep_source, ManifestLockfileDependencySource)
+        else None
+    )
+
+    resolved_deps, parse_errors = parser(lockfile_path, manifest_path)
+
+    return (
+        (ecosystem, ResolutionMethod.LOCKFILE_PARSING, resolved_deps),
+        parse_errors,
+        [lockfile_path],
+    )
+
+
+def resolve_dependency_source(
+    dep_source: DependencySource,
+    enable_dynamic_resolution: bool = True,
+    prioritize_dependency_graph_generation: bool = False,
+) -> DependencyResolutionResult:
+    """
+    Resolve the dependencies in the dependency source. Returns:
+    - The ecosystem the resulting dependencies belong to. If there are no dependencies, this value is None
+    - The list of FoundDependency objects that were resolved
+    - The list of dependency parser errors encountered
+    - The list of paths that should be considered dependency targets
+    """
+    if isinstance(dep_source, LockfileOnlyDependencySource) or isinstance(
+        dep_source, ManifestLockfileDependencySource
+    ):
+        return _handle_lockfile_source(
+            dep_source,
+            enable_dynamic_resolution,
+            prioritize_dependency_graph_generation,
+        )
+    elif isinstance(dep_source, MultiLockfileDependencySource):
+        return _handle_multi_lockfile_source(dep_source)
+    elif (
+        isinstance(dep_source, ManifestOnlyDependencySource)
+        and enable_dynamic_resolution
+    ):
+        return _handle_manifest_only_source(dep_source)
+    else:
+        # dependency source type is not supported, do nothing
+        return (None, [], [])
diff --git a/cli/src/semgrep/resolve_subprojects.py b/cli/src/semgrep/resolve_subprojects.py
index 40dbfa7018c9..b52b9a42000d 100644
--- a/cli/src/semgrep/resolve_subprojects.py
+++ b/cli/src/semgrep/resolve_subprojects.py
@@ -2,49 +2,13 @@
 from typing import Dict
 from typing import FrozenSet
 from typing import List
-from typing import Optional
-from typing import Sequence
 from typing import Set
 from typing import Tuple
-from typing import Union
 
-import semgrep.semgrep_interfaces.semgrep_output_v1 as out
-from semdep.parsers.cargo import parse_cargo
-from semdep.parsers.composer import parse_composer_lock
-from semdep.parsers.gem import parse_gemfile
-from semdep.parsers.go_mod import parse_go_mod
-from semdep.parsers.gradle import parse_gradle
-from semdep.parsers.mix import parse_mix
-from semdep.parsers.package_lock import parse_package_lock
-from semdep.parsers.packages_lock_c_sharp import (
-    parse_packages_lock as parse_packages_lock_c_sharp,
-)
-from semdep.parsers.pipfile import parse_pipfile
-from semdep.parsers.pnpm import parse_pnpm
-from semdep.parsers.poetry import parse_poetry
-from semdep.parsers.pom_tree import parse_pom_tree
-from semdep.parsers.pubspec_lock import parse_pubspec_lock
-from semdep.parsers.requirements import parse_requirements
-from semdep.parsers.swiftpm import parse_package_resolved
-from semdep.parsers.util import DependencyParserError
-from semdep.parsers.util import SemgrepParser
-from semdep.parsers.util import to_parser
-from semdep.parsers.yarn import parse_yarn
 from semdep.subproject_matchers import MATCHERS
 from semdep.subproject_matchers import SubprojectMatcher
-from semgrep.error import DependencyResolutionError
-from semgrep.rpc_call import resolve_dependencies
-from semgrep.semgrep_interfaces.semgrep_output_v1 import CargoParser
-from semgrep.semgrep_interfaces.semgrep_output_v1 import DependencyParserError
+from semgrep.resolve_dependency_source import resolve_dependency_source
 from semgrep.semgrep_interfaces.semgrep_output_v1 import Ecosystem
-from semgrep.semgrep_interfaces.semgrep_output_v1 import FoundDependency
-from semgrep.semgrep_interfaces.semgrep_output_v1 import ScaParserName
-from semgrep.subproject import DependencySource
-from semgrep.subproject import LockfileOnlyDependencySource
-from semgrep.subproject import ManifestLockfileDependencySource
-from semgrep.subproject import ManifestOnlyDependencySource
-from semgrep.subproject import MultiLockfileDependencySource
-from semgrep.subproject import ResolutionMethod
 from semgrep.subproject import ResolvedSubproject
 from semgrep.subproject import Subproject
 from semgrep.subproject import UnresolvedSubproject
@@ -53,188 +17,6 @@
 
 logger = getLogger(__name__)
 
-# argument order is lockfile path, manifest path
-PARSERS_BY_LOCKFILE_KIND: Dict[out.LockfileKind, SemgrepParser] = {
-    out.LockfileKind(out.PipfileLock()): parse_pipfile,
-    out.LockfileKind(out.PipRequirementsTxt()): parse_requirements,
-    out.LockfileKind(out.PoetryLock_()): parse_poetry,
-    out.LockfileKind(out.NpmPackageLockJson()): parse_package_lock,
-    out.LockfileKind(out.YarnLock()): parse_yarn,
-    out.LockfileKind(out.PnpmLock_()): parse_pnpm,
-    out.LockfileKind(out.GemfileLock_()): parse_gemfile,
-    out.LockfileKind(out.ComposerLock_()): parse_composer_lock,
-    out.LockfileKind(out.GoMod2()): parse_go_mod,
-    out.LockfileKind(out.CargoLock()): to_parser(
-        parse_cargo, ScaParserName(CargoParser())
-    ),
-    out.LockfileKind(out.MavenDepTree()): parse_pom_tree,
-    out.LockfileKind(out.GradleLockfile_()): parse_gradle,
-    out.LockfileKind(out.NugetPackagesLockJson()): parse_packages_lock_c_sharp,
-    out.LockfileKind(out.PubspecLock_()): parse_pubspec_lock,
-    out.LockfileKind(out.SwiftPackageResolved()): parse_package_resolved,
-    out.LockfileKind(out.MixLock_()): parse_mix,
-}
-
-ECOSYSTEM_BY_LOCKFILE_KIND: Dict[out.LockfileKind, Ecosystem] = {
-    out.LockfileKind(out.PipfileLock()): Ecosystem(out.Pypi()),
-    out.LockfileKind(out.PipRequirementsTxt()): Ecosystem(out.Pypi()),
-    out.LockfileKind(out.PoetryLock_()): Ecosystem(out.Pypi()),
-    out.LockfileKind(out.NpmPackageLockJson()): Ecosystem(out.Npm()),
-    out.LockfileKind(out.YarnLock()): Ecosystem(out.Npm()),
-    out.LockfileKind(out.PnpmLock_()): Ecosystem(out.Npm()),
-    out.LockfileKind(out.GemfileLock_()): Ecosystem(out.Gem()),
-    out.LockfileKind(out.ComposerLock_()): Ecosystem(out.Composer()),
-    out.LockfileKind(out.GoMod2()): Ecosystem(out.Gomod()),
-    out.LockfileKind(out.CargoLock()): Ecosystem(out.Cargo()),
-    out.LockfileKind(out.MavenDepTree()): Ecosystem(out.Maven()),
-    out.LockfileKind(out.GradleLockfile_()): Ecosystem(out.Maven()),
-    out.LockfileKind(out.NugetPackagesLockJson()): Ecosystem(out.Nuget()),
-    out.LockfileKind(out.PubspecLock_()): Ecosystem(out.Pub()),
-    out.LockfileKind(out.SwiftPackageResolved()): Ecosystem(out.SwiftPM()),
-    out.LockfileKind(out.MixLock_()): Ecosystem(out.Mix()),
-}
-
-DEPENDENCY_GRAPH_SUPPORTED_MANIFEST_KINDS = [
-    out.ManifestKind(out.PomXml()),
-    out.ManifestKind(out.BuildGradle()),
-]
-
-
-def _resolve_dependencies_dynamically(
-    dependency_source: Union[
-        ManifestOnlyDependencySource, ManifestLockfileDependencySource
-    ]
-) -> Tuple[
-    Optional[Tuple[Ecosystem, List[FoundDependency]]],
-    Sequence[Union[DependencyParserError, DependencyResolutionError]],
-    List[Path],
-]:
-    """
-    Handle the RPC call to resolve dependencies dynamically.
-    """
-    response = resolve_dependencies([dependency_source.to_semgrep_output()])
-    if response is None:
-        # we failed to resolve somehow
-        # TODO: handle this and generate an error
-        return None, [], []
-    if len(response) > 1:
-        logger.warning(
-            f"Too many responses from dynamic dependency resolution RPC. Expected 1, got {len(response)}"
-        )
-    result = response[0][1]
-    if isinstance(result.value, out.ResolutionOk):
-        resolved_deps, errors = result.value.value
-        # right now we only support lockfileless for the maven ecosystem, so hardcode that here
-        # TODO: move this ecosystem identification into the ocaml code when we redo the interface there
-        ecosystem = Ecosystem(out.Maven())
-        wrapped_errors = [
-            DependencyResolutionError(
-                type_=e_type,
-                dependency_source_file=Path(dependency_source.manifest.path.value),
-            )
-            for e_type in errors
-        ]
-        return (
-            (ecosystem, resolved_deps),
-            wrapped_errors,
-            [Path(dependency_source.manifest.path.value)],
-        )
-    else:
-        # some error occured in resolution, track it
-        wrapped_errors = [
-            DependencyResolutionError(
-                type_=e_type,
-                dependency_source_file=Path(dependency_source.manifest.path.value),
-            )
-            for e_type in result.value.value
-        ]
-        return (None, wrapped_errors, [])
-
-
-def _resolve_dependency_source(
-    dep_source: DependencySource,
-    enable_dynamic_resolution: bool = True,
-    prioritize_dependency_graph_generation: bool = False,
-) -> Tuple[
-    Optional[Ecosystem],
-    List[FoundDependency],
-    Sequence[Union[DependencyParserError, DependencyResolutionError]],
-    List[Path],
-]:
-    """
-    Resolve the dependencies in the dependency source. Returns:
-    - The ecosystem the resulting dependencies belong to. If there are no dependencies, this value is None
-    - The list of FoundDependency objects that were resolved
-    - The list of dependency parser errors encountered
-    - The list of paths that should be considered dependency targets
-    """
-    ecosystem: Optional[Ecosystem] = None
-
-    if isinstance(dep_source, LockfileOnlyDependencySource) or isinstance(
-        dep_source, ManifestLockfileDependencySource
-    ):
-        lockfile_path = Path(dep_source.lockfile.path.value)
-        parser = PARSERS_BY_LOCKFILE_KIND[dep_source.lockfile.kind]
-        ecosystem = ECOSYSTEM_BY_LOCKFILE_KIND[dep_source.lockfile.kind]
-        if (
-            enable_dynamic_resolution
-            and prioritize_dependency_graph_generation
-            and isinstance(dep_source, ManifestLockfileDependencySource)
-            and dep_source.manifest.kind in DEPENDENCY_GRAPH_SUPPORTED_MANIFEST_KINDS
-        ):
-            (
-                resolved_info,
-                new_errors,
-                new_targets,
-            ) = _resolve_dependencies_dynamically(dep_source)
-            manifest_path = Path(dep_source.manifest.path.value)
-            if resolved_info is not None:
-                # TODO: Reimplement this once more robust error handling for lockfileless resolution is implemented
-                new_ecosystem, new_deps = resolved_info
-                return new_ecosystem, new_deps, new_errors, new_targets
-            else:
-                # dynamic resolution failed, fall back to lockfile parsing
-                resolved_deps, parse_errors = parser(lockfile_path, manifest_path)
-            return ecosystem, resolved_deps, parse_errors, [lockfile_path]
-        else:
-            resolved_deps, parse_errors = parser(
-                lockfile_path,
-                Path(dep_source.manifest.path.value)
-                if isinstance(dep_source, ManifestLockfileDependencySource)
-                else None,
-            )
-            return ecosystem, resolved_deps, parse_errors, [lockfile_path]
-    elif isinstance(dep_source, MultiLockfileDependencySource):
-        all_resolved_deps: List[FoundDependency] = []
-        all_parse_errors: List[
-            Union[DependencyParserError, DependencyResolutionError]
-        ] = []
-        all_dep_targets: List[Path] = []
-
-        for lockfile_source in dep_source.sources:
-            ecosystem, new_deps, new_errors, new_targets = _resolve_dependency_source(
-                lockfile_source
-            )
-            all_resolved_deps.extend(new_deps)
-            all_parse_errors.extend(new_errors)
-            all_dep_targets.extend(new_targets)
-
-        return ecosystem, all_resolved_deps, all_parse_errors, all_dep_targets
-    elif (
-        isinstance(dep_source, ManifestOnlyDependencySource)
-        and enable_dynamic_resolution
-    ):
-        resolved_info, new_errors, new_targets = _resolve_dependencies_dynamically(
-            dep_source
-        )
-        if resolved_info is None:
-            return None, [], new_errors, new_targets
-        new_ecosystem, new_deps = resolved_info
-        return new_ecosystem, new_deps, new_errors, new_targets
-    else:
-        # dependency source type is not supported, do nothing
-        return (None, [], [], [])
-
 
 def find_subprojects(
     dependency_source_files: FrozenSet[Path], matchers: List[SubprojectMatcher]
@@ -290,17 +72,18 @@ def resolve_subprojects(
     unresolved: List[UnresolvedSubproject] = []
     # Dispatch each subproject to a resolver for resolution
     for to_resolve in found_subprojects:
-        ecosystem, deps, errors, targets = _resolve_dependency_source(
+        resolved_info, errors, targets = resolve_dependency_source(
             to_resolve.dependency_source,
             allow_dynamic_resolution,
             prioritize_dependency_graph_generation,
         )
         dependency_targets.extend(targets)
 
-        if ecosystem is not None:
-            # ecosystem is only None when dependency resolution failed in some way
+        if resolved_info is not None:
+            # resolved_info is only None when dependency resolution failed in some way
+            ecosystem, resolution_method, deps = resolved_info
             resolved_subproject = ResolvedSubproject.from_unresolved(
-                to_resolve, ResolutionMethod.LOCKFILE_PARSING, errors, deps, ecosystem
+                to_resolve, resolution_method, errors, deps, ecosystem
             )
 
             if ecosystem not in resolved:
diff --git a/cli/src/semgrep/run_scan.py b/cli/src/semgrep/run_scan.py
index 6f17692298ff..d58ad06f7e6c 100644
--- a/cli/src/semgrep/run_scan.py
+++ b/cli/src/semgrep/run_scan.py
@@ -256,6 +256,7 @@ def run_rules(
     Dict[str, List[FoundDependency]],
     List[DependencyParserError],
     List[Plan],
+    List[Union[UnresolvedSubproject, ResolvedSubproject]],
 ]:
     if not target_mode_config:
         target_mode_config = TargetModeConfig.whole_scan()
@@ -274,6 +275,7 @@ def run_rules(
 
     resolved_subprojects: Dict[Ecosystem, List[ResolvedSubproject]] = {}
     unresolved_subprojects: List[UnresolvedSubproject] = []
+    all_subprojects: List[Union[ResolvedSubproject, UnresolvedSubproject]] = []
 
     if len(dependency_aware_rules) > 0:
         # Parse lockfiles to get dependency information, if there are relevant rules
@@ -288,7 +290,6 @@ def run_rules(
         )
         # for each subproject, split the errors into semgrep errors and parser errors.
         # output the semgrep errors and store the parser errors for printing in print_scan_status below
-        all_subprojects: List[Union[ResolvedSubproject, UnresolvedSubproject]] = []
         all_subprojects.extend(unresolved_subprojects)
         for subprojects in resolved_subprojects.values():
             all_subprojects.extend(subprojects)
@@ -448,6 +449,7 @@ def run_rules(
         deps_by_lockfile,
         dependency_parser_errors,
         plans,
+        all_subprojects,
     )
 
 
@@ -539,6 +541,7 @@ def run_scan(
     List[DependencyParserError],
     int,  # Executed Rule Count
     int,  # Missed Rule Count
+    List[Union[UnresolvedSubproject, ResolvedSubproject]],
 ]:
     logger.debug(f"semgrep version {__VERSION__}")
 
@@ -782,6 +785,7 @@ def run_scan(
         dependencies,
         dependency_parser_errors,
         plans,
+        all_subprojects,
     ) = run_rules(
         filtered_rules,
         target_manager,
@@ -885,6 +889,7 @@ def run_scan(
                         _,
                         _,
                         _plans,
+                        _,
                     ) = run_rules(
                         # only the rules that had a match
                         [
@@ -965,6 +970,7 @@ def run_scan(
         dependency_parser_errors,
         executed_rule_count,
         missed_rule_count,
+        all_subprojects,
     )
 
 
@@ -1002,6 +1008,7 @@ def run_scan_and_return_json(
         _,
         _,
         _,
+        _all_subprojects,
     ) = run_scan(
         output_handler=output_handler,
         target=[str(t) for t in targets],
diff --git a/cli/src/semgrep/semgrep_interfaces b/cli/src/semgrep/semgrep_interfaces
index 1c6af386e5e3..b35746b9ec44 160000
--- a/cli/src/semgrep/semgrep_interfaces
+++ b/cli/src/semgrep/semgrep_interfaces
@@ -1,1 +1,1 @@
-Subproject commit 1c6af386e5e3497c8a384ed9a9fcf2814c76c892
+Subproject commit b35746b9ec4472a0de0dc6c48a0769518ba429dd
diff --git a/cli/src/semgrep/subproject.py b/cli/src/semgrep/subproject.py
index f0d62f9bc121..576a6485bd9c 100644
--- a/cli/src/semgrep/subproject.py
+++ b/cli/src/semgrep/subproject.py
@@ -1,3 +1,4 @@
+import hashlib
 from abc import ABC
 from abc import abstractmethod
 from collections import defaultdict
@@ -22,14 +23,30 @@
 
 
 class ResolutionMethod(Enum):
+    # we parsed a lockfile that was already included in the repository
     LOCKFILE_PARSING = auto()
 
+    # we communicated with the package manager to resolve dependencies
+    DYNAMIC = auto()
+
+    def to_stats_output(self) -> out.ResolutionMethod:
+        if self == ResolutionMethod.LOCKFILE_PARSING:
+            return out.ResolutionMethod(value=out.LockfileParsing())
+        elif self == ResolutionMethod.DYNAMIC:
+            return out.ResolutionMethod(value=out.DynamicResolution())
+        else:
+            raise ValueError(f"Unsupported resolution method: {self}")
+
 
 class DependencySource(ABC):
     @abstractmethod
     def get_display_paths(self) -> List[Path]:
         return []
 
+    @abstractmethod
+    def to_stats_output(self) -> List[out.DependencySourceFile]:
+        pass
+
 
 @dataclass(frozen=True)
 class ManifestOnlyDependencySource(DependencySource):
@@ -41,6 +58,16 @@ def get_display_paths(self) -> List[Path]:
     def to_semgrep_output(self) -> out.DependencySource:
         return out.DependencySource(out.ManifestOnlyDependencySource(self.manifest))
 
+    def to_stats_output(self) -> List[out.DependencySourceFile]:
+        return [
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Manifest_(value=self.manifest.kind)
+                ),
+                path=self.manifest.path,
+            )
+        ]
+
 
 @dataclass(frozen=True)
 class LockfileOnlyDependencySource(DependencySource):
@@ -52,6 +79,16 @@ def get_display_paths(self) -> List[Path]:
     def to_semgrep_output(self) -> out.DependencySource:
         return out.DependencySource(out.LockfileOnlyDependencySource(self.lockfile))
 
+    def to_stats_output(self) -> List[out.DependencySourceFile]:
+        return [
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Lockfile_(value=self.lockfile.kind)
+                ),
+                path=self.lockfile.path,
+            )
+        ]
+
 
 @dataclass(frozen=True)
 class ManifestLockfileDependencySource(DependencySource):
@@ -66,6 +103,23 @@ def to_semgrep_output(self) -> out.DependencySource:
             out.ManifestLockfileDependencySource((self.manifest, self.lockfile))
         )
 
+    def to_stats_output(self) -> List[out.DependencySourceFile]:
+        lockfile_entry = out.DependencySourceFile(
+            kind=out.DependencySourceFileKind(
+                value=out.Lockfile_(value=self.lockfile.kind)
+            ),
+            path=self.lockfile.path,
+        )
+
+        manifest_entry = out.DependencySourceFile(
+            kind=out.DependencySourceFileKind(
+                value=out.Manifest_(value=self.manifest.kind)
+            ),
+            path=self.manifest.path,
+        )
+
+        return [lockfile_entry, manifest_entry]
+
 
 @dataclass(frozen=True)
 class MultiLockfileDependencySource(DependencySource):
@@ -78,6 +132,9 @@ def get_display_paths(self) -> List[Path]:
         # aggregate all display paths for each of the child sources
         return [path for source in self.sources for path in source.get_display_paths()]
 
+    def to_stats_output(self) -> List[out.DependencySourceFile]:
+        return [item for source in self.sources for item in source.to_stats_output()]
+
 
 @dataclass(frozen=True)
 class ResolvedDependencies:
@@ -199,6 +256,12 @@ def pretty_print_dependency(
         for dependency in unknown:
             print(f"- {dependency.package}@{dependency.version}")
 
+    def count(self) -> int:
+        """
+        Count the number of dependencies
+        """
+        return sum(1 for _ in self.iter_found_dependencies())
+
 
 @dataclass(frozen=True)
 class Subproject:
@@ -216,6 +279,21 @@ class Subproject:
     # but in the future it might also be dynamic resolution based on a manifest, an SBOM, or something else
     dependency_source: DependencySource
 
+    def to_stats_output(self) -> out.SubprojectStats:
+        # subproject id is a hash based on the dependency field paths
+        normalized_paths = sorted(
+            str(path).strip() for path in self.dependency_source.get_display_paths()
+        )
+        subproject_id = hashlib.sha256(
+            "".join(normalized_paths).encode("utf-8")
+        ).hexdigest()
+
+        return out.SubprojectStats(
+            subproject_id=subproject_id,
+            dependency_sources=self.dependency_source.to_stats_output(),
+            resolved_stats=None,
+        )
+
 
 @dataclass(frozen=True)
 class UnresolvedSubproject(Subproject):
@@ -279,6 +357,19 @@ def from_unresolved(
             resolution_method=resolution_method,
         )
 
+    def to_stats_output(self) -> out.SubprojectStats:
+        base_stats = super().to_stats_output()
+
+        return out.SubprojectStats(
+            subproject_id=base_stats.subproject_id,
+            dependency_sources=base_stats.dependency_sources,
+            resolved_stats=out.DependencyResolutionStats(
+                ecosystem=self.ecosystem,
+                resolution_method=self.resolution_method.to_stats_output(),
+                dependency_count=self.found_dependencies.count(),
+            ),
+        )
+
 
 def find_closest_subproject(
     path: Path, ecosystem: Ecosystem, candidates: List[ResolvedSubproject]
diff --git a/languages/yaml/generic/yaml_to_generic.ml b/languages/yaml/generic/yaml_to_generic.ml
index 0b62d3e4dca9..0780ed4cd168 100644
--- a/languages/yaml/generic/yaml_to_generic.ml
+++ b/languages/yaml/generic/yaml_to_generic.ml
@@ -12,7 +12,6 @@
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
  * LICENSE for more details.
  *)
-open Fpath_.Operators
 module Y = Yaml
 module S = Yaml.Stream
 module E = Yaml.Stream.Event
@@ -629,7 +628,7 @@ let parse_yaml_file ~is_target (file : Fpath.t) str =
   (* we do not preprocess the yaml here; ellipsis should be transformed
    * only in the pattern *)
   let bytepos_to_pos =
-    Some (Pos.full_converters_large !!file).bytepos_to_linecol_fun
+    Some (Pos.full_converters_large file).bytepos_to_linecol_fun
   in
   let parser = get_res file (S.parser str) in
   let env =
diff --git a/libs/commons/UFile.ml b/libs/commons/UFile.ml
index c523b8acd782..8088101c077c 100644
--- a/libs/commons/UFile.ml
+++ b/libs/commons/UFile.ml
@@ -50,7 +50,7 @@ module Legacy = struct
     let chan = UStdlib.open_in_bin file in
     try
       while true do
-        acc := input_text_line chan :: !acc
+        acc := Common.input_text_line chan :: !acc
       done;
       assert false
     with
diff --git a/libs/lib_parsing/Parsing_helpers.ml b/libs/lib_parsing/Parsing_helpers.ml
index 2fdad5f86a66..e1c8fc932636 100644
--- a/libs/lib_parsing/Parsing_helpers.ml
+++ b/libs/lib_parsing/Parsing_helpers.ml
@@ -88,7 +88,8 @@ let yyback n lexbuf =
  *  - we can have comments as tokens (useful for codemap/efuns) and
  *    skip them easily with one Common.exclude
  *)
-let tokenize_and_adjust_pos lexbuf table filename tokenizer visitor_tok is_eof =
+let tokenize_and_adjust_pos lexbuf table (filename : Fpath.t) tokenizer
+    visitor_tok is_eof =
   let adjust_info (ii : Tok.t) =
     (* could assert pinfo.filename = file ? *)
     Tok.(
@@ -117,12 +118,12 @@ let tokenize_all_and_adjust_pos input_source tokenizer visitor_tok is_eof =
   | Str str ->
       let lexbuf = Lexing.from_string str in
       let table = Pos.full_converters_str str in
-      (* TODO: don't pass "<file>" where an actual file is expected.
+      (* TODO: don't use fake_file! where an actual file is expected.
          This results in cryptic errors later when the file can't be opened. *)
-      tokenize_and_adjust_pos lexbuf table "<file>" tokenizer visitor_tok is_eof
-  | File path ->
-      let file = Fpath.to_string path in
-      UFile.Legacy.with_open_infile file (fun chan ->
+      tokenize_and_adjust_pos lexbuf table Fpath_.fake_file tokenizer
+        visitor_tok is_eof
+  | File file ->
+      UFile.with_open_in file (fun chan ->
           let lexbuf = Lexing.from_channel chan in
           let table = Pos.full_converters_large file in
           tokenize_and_adjust_pos lexbuf table file tokenizer visitor_tok is_eof)
diff --git a/libs/lib_parsing/Pos.ml b/libs/lib_parsing/Pos.ml
index 061d28b2c3cd..df756779677f 100644
--- a/libs/lib_parsing/Pos.ml
+++ b/libs/lib_parsing/Pos.ml
@@ -110,10 +110,10 @@ type bytepos_linecol_converters = {
  *   - in each lexer you need to take care of newlines and update manually
  *     the field.
  *)
-let complete_position (filename : string) converters (x : t) =
+let complete_position (file : Fpath.t) converters (x : t) =
   {
     x with
-    file = Fpath.v filename;
+    file;
     line = fst (converters.bytepos_to_linecol_fun x.bytepos);
     column = snd (converters.bytepos_to_linecol_fun x.bytepos);
   }
@@ -163,8 +163,8 @@ let converters_of_arrays line_arr col_arr : bytepos_linecol_converters =
              | Ok (bytepos, _) -> bytepos);
       }
 
-let full_converters_large (file : string) : bytepos_linecol_converters =
-  let size = UFile.filesize (Fpath.v file) + 2 in
+let full_converters_large (file : Fpath.t) : bytepos_linecol_converters =
+  let size = UFile.filesize file + 2 in
 
   (* old: let arr = Array.create size  (0,0) in *)
   let arr1 = Bigarray.Array1.create Bigarray.int Bigarray.c_layout size in
@@ -175,7 +175,7 @@ let full_converters_large (file : string) : bytepos_linecol_converters =
   let charpos = ref 0 in
   let line = ref 0 in
 
-  UFile.Legacy.with_open_infile file (fun chan ->
+  UFile.with_open_in file (fun chan ->
       let full_charpos_to_pos_aux () =
         try
           while true do
diff --git a/libs/lib_parsing/Pos.mli b/libs/lib_parsing/Pos.mli
index 2a70dfaf5f83..7037a02cebc1 100644
--- a/libs/lib_parsing/Pos.mli
+++ b/libs/lib_parsing/Pos.mli
@@ -56,11 +56,10 @@ type bytepos_linecol_converters = {
  *)
 
 (* f(i) will contain the (line x col) of the i char position *)
-val full_converters_large : string (* filename *) -> bytepos_linecol_converters
+val full_converters_large : Fpath.t -> bytepos_linecol_converters
 val full_converters_str : string -> bytepos_linecol_converters
 
 (* fill in the line and column field of a position that were not set
  * during lexing because of limitations of ocamllex and Lexing.position.
  *)
-val complete_position :
-  string (* filename *) -> bytepos_linecol_converters -> t -> t
+val complete_position : Fpath.t -> bytepos_linecol_converters -> t -> t
diff --git a/libs/lib_parsing/Tok.ml b/libs/lib_parsing/Tok.ml
index 20586bdc7d86..1d509fc33c99 100644
--- a/libs/lib_parsing/Tok.ml
+++ b/libs/lib_parsing/Tok.ml
@@ -537,7 +537,7 @@ let fix_pos fix loc = { loc with pos = fix loc.pos }
 (* Adjust line x col *)
 (*****************************************************************************)
 
-let complete_location filename table (x : location) =
+let complete_location (filename : Fpath.t) table (x : location) =
   { x with pos = Pos.complete_position filename table x.pos }
 
 (*
diff --git a/libs/lib_parsing/Tok.mli b/libs/lib_parsing/Tok.mli
index 6eae08cdc9ca..b7e5d8d1c4aa 100644
--- a/libs/lib_parsing/Tok.mli
+++ b/libs/lib_parsing/Tok.mli
@@ -253,10 +253,7 @@ val adjust_loc_wrt_base : location -> location -> location
  * during lexing because of limitations of ocamllex and Lexing.position.
  *)
 val complete_location :
-  string (* filename *) ->
-  Pos.bytepos_linecol_converters ->
-  location ->
-  location
+  Fpath.t -> Pos.bytepos_linecol_converters -> location -> location
 
 (*****************************************************************************)
 (* Misc *)
diff --git a/libs/networking/http_mock_client/Http_mock_client.ml b/libs/networking/http_mock_client/Http_mock_client.ml
index 6cfcd15f8c5e..01da74f448a3 100644
--- a/libs/networking/http_mock_client/Http_mock_client.ml
+++ b/libs/networking/http_mock_client/Http_mock_client.ml
@@ -123,8 +123,8 @@ let basic_response ?(status = 200) ?(headers = Header.init ()) body =
   let response = Response.make ~status ~headers ~flush:true () in
   { response; body }
 
-let body_of_file ?(trim = false) path =
-  let content = UFile.Legacy.read_file path in
+let body_of_file ?(trim = false) (path : Fpath.t) =
+  let content = UFile.read_file path in
   let content = if trim then String.trim content else content in
   Cohttp_lwt.Body.of_string content
 
@@ -327,8 +327,8 @@ let parse_resp =
           let body = String.concat "\n" body |> Body.of_string in
           (Cohttp.Response.make ~version ~status ~flush:true ~headers (), body))
 
-let client_from_file req_resp_file =
-  let contents = UFile.Legacy.read_file req_resp_file in
+let client_from_file (req_resp_file : Fpath.t) =
+  let contents = UFile.read_file req_resp_file in
   let rec go s acc =
     if String.length s = 0 then acc
     else if not (List.mem (String.get s 0) [ '>'; '<' ]) then
diff --git a/libs/networking/http_mock_client/Http_mock_client.mli b/libs/networking/http_mock_client/Http_mock_client.mli
index a89a8be1be69..f3044a3b371d 100644
--- a/libs/networking/http_mock_client/Http_mock_client.mli
+++ b/libs/networking/http_mock_client/Http_mock_client.mli
@@ -1,13 +1,14 @@
-(* Commentary *)
-(* This is a testing client to mock out http_helpers.ml so we
- * can check requests and insert responses. It works by replacing
- * the Cohttp client module with a custom one that calls
- * a make response function. The make response function
- * is called to check a request and create a response to
-   return to the caller.
- * Example usage:
-let with_foo_client =
-    let make_fn = (fun req body ->
+(*
+  This is a testing client to mock out http_helpers.ml so we
+  can check requests and insert responses. It works by replacing
+  the Cohttp client module with a custom one that calls
+  a make_response function. The make_response function
+  is called to check a request and create a response to
+  return to the caller.
+
+  Example usage:
+  let with_foo_client =
+    let make_response_fn = (fun req body ->
         match Uri.path (Cohttp.Request.uri req) with
         | "http://foo.com/api/v1/blah" ->
             Http_mock_client.check_method `GET req.meth;
@@ -20,7 +21,7 @@ let with_foo_client =
         | _ -> Alcotest.fail "unexpected request"
     )
     in
-    Http_mock_client.with_testing_client make_fn
+    Http_mock_client.with_testing_client make_response_fn
    ...
 
    let test_foo = ... in
@@ -33,15 +34,12 @@ let with_foo_client =
  *)
 
 type test_response = { response : Cohttp.Response.t; body : Cohttp_lwt.Body.t }
-(** [test_response] is a response (headers and status), and a path to a file
-  * which will make the body of the response. The file is simply read and it's
-  * exact bytes are returned
-  *)
+(** [test_response] is a response (headers and status), and body *)
 
 type make_response_fn =
   Cohttp.Request.t -> Cohttp_lwt.Body.t -> test_response Lwt.t
 (** [make_response_fn requst body] takes in a request and its body, and
-  * must return a response (see [basic_response]), and a path to a body
+  * must return a response (see [basic_response]), and a body
   * (see [test_response]).
   *)
 
@@ -52,7 +50,7 @@ val basic_response :
   * Example: [basic_response "{ \"api_code\": 304, \"info\": \"example\" }"].
   *)
 
-val body_of_file : ?trim:bool -> string -> Cohttp_lwt.Body.t
+val body_of_file : ?trim:bool -> Fpath.t -> Cohttp_lwt.Body.t
 (** [body_of_file trim path_to_body] will return a body which is the exact bytes
   * of the file [path_to_body], unless [trim] is true, in which case the bytes
   * will be trimmed of leading and trailing whitespace.
@@ -60,19 +58,18 @@ val body_of_file : ?trim:bool -> string -> Cohttp_lwt.Body.t
 
 val check_body : Cohttp_lwt.Body.t -> Cohttp_lwt.Body.t -> unit Lwt.t
 (** [check_body expected_body actual_body] will check a request/response has the
-  * exact same bytes as the file [path_to_body]. Uses Alcotest to assert the
-  * bodies are equal.
+  * exact same bytes. Uses Alcotest to assert the bodies are equal.
   *)
 
 val check_method : Cohttp.Code.meth -> Cohttp.Code.meth -> unit
-(** [check_method expected_meth actual_meth] will use Alcotest to assert a request
-  * was made with a certain http method.
+(** [check_method expected_meth actual_meth] will use Alcotest to assert a
+  * request was made with a certain http method.
   * Example: [check_method `GET request.meth]
   *)
 
 val check_header : Cohttp.Request.t -> string -> string -> unit
-(** [check_header request header header_value] will use Alcotest to assert a request
-  * was made with a certain header and value
+(** [check_header request header header_value] will use Alcotest to assert a
+  * request was made with a certain header and value
   * Example: [check_header request "Authorization" "Bearer <token>"]
   *)
 
@@ -90,22 +87,22 @@ val get_header : Cohttp.Request.t -> string -> string option
   *)
 
 val with_testing_client : make_response_fn -> (unit -> 'a) -> unit -> 'a
-(** [with_testing_client make_fn f ()]
+(** [with_testing_client make_response_fn f ()]
   * will modify the [http_helpers] client to not actually make
   * http requests, but instead will replace it with a client
-  * that uses [make_fn] to respond.
+  * that uses [make_response_fn] to respond.
   * Any code inside [f] will be run with this modified client.
   *)
 
 val client_from_file :
-  string ->
+  Fpath.t ->
   (module Cohttp_lwt.S.Client)
   * ((((Cohttp.Request.t * Cohttp_lwt.Body.t)
       * (Cohttp.Response.t * Cohttp_lwt.Body.t))
       list ->
      'a) ->
     'a)
-(** [client_from_file "request-response-file.network"]
+(** [client_from_file (Fpath.v "request-response-file.network")]
   * will return a new http client which may be used in lieu of the
   * [http_helpers] client, which rather than performing network requests will
   * instead look for a matching request in the given file and yield the
diff --git a/libs/paths/Project.ml b/libs/paths/Project.ml
index 45f3a5eea799..0a7a60864476 100644
--- a/libs/paths/Project.ml
+++ b/libs/paths/Project.ml
@@ -20,7 +20,7 @@ and kind =
   | Subversion_project
   | Darcs_project
   | Gitignore_project
-  | Other_project
+  | No_VCS_project
 [@@deriving show]
 
 type roots = {
@@ -131,25 +131,30 @@ let force_project_root ?(project_root : Rfpath.t option) (path : Fpath.t) :
   | Ok inproject_path -> { project_root; inproject_path }
   | Error msg -> failwith msg
 
-let find_any_project_root ?fallback_root ?force_root (fpath : Fpath.t) :
-    kind * scanning_root_info =
+let find_any_project_root ~fallback_root ~force_novcs ~force_root
+    (fpath : Fpath.t) : kind * scanning_root_info =
   Log.debug (fun m ->
       m "find_any_project_root: fallback_root=%s force_root=%s %s"
         (Logs_.option Rfpath.show fallback_root)
         (Logs_.option show force_root)
         !!fpath);
-  match force_root with
-  | Some { kind; root = project_root } ->
-      (kind, force_project_root ~project_root fpath)
-  | None -> (
-      match get_project_root_of_fpath_opt fpath with
-      | Some (kind, project_root) ->
-          let project_root = Rfpath.of_fpath_exn project_root in
-          let inproject_path =
-            match Ppath.in_project ~root:project_root fpath with
-            | Ok x -> x
-            | Error msg -> failwith msg
-          in
-          (kind, { project_root; inproject_path })
-      | None ->
-          (Other_project, force_project_root ?project_root:fallback_root fpath))
+  let inferred_kind, root_info =
+    match force_root with
+    | Some { kind; root = project_root } ->
+        (kind, force_project_root ~project_root fpath)
+    | None -> (
+        match get_project_root_of_fpath_opt fpath with
+        | Some (kind, project_root) ->
+            let project_root = Rfpath.of_fpath_exn project_root in
+            let inproject_path =
+              match Ppath.in_project ~root:project_root fpath with
+              | Ok x -> x
+              | Error msg -> failwith msg
+            in
+            (kind, { project_root; inproject_path })
+        | None ->
+            ( No_VCS_project,
+              force_project_root ?project_root:fallback_root fpath ))
+  in
+  let kind = if force_novcs then No_VCS_project else inferred_kind in
+  (kind, root_info)
diff --git a/libs/paths/Project.mli b/libs/paths/Project.mli
index 655ad8e862ff..193822ffa381 100644
--- a/libs/paths/Project.mli
+++ b/libs/paths/Project.mli
@@ -7,23 +7,27 @@ type t = { kind : kind; root : Rfpath.t } [@@deriving show]
    system (VCS) it uses.
 *)
 and kind =
-  (* .git/ *)
+  (* A git project as determined by calling git. *)
   | Git_project
-  (* .hg/ *)
+  (* TODO: these other VCS aren't fully supported (ignored paths and/or
+     fast listing of project files like we do with git).
+     They're only used to determine the project root. *)
   | Mercurial_project
-  (* .svn/ *)
   | Subversion_project
-  (* _darcs/ *)
   | Darcs_project
   (* A gitignore project is a fake git project. It doesn't have a valid
    *  '.git/' folder but its '.gitignore' files should be read as part
    * of the semgrepignore mechanism.
    *)
   | Gitignore_project
-  (* whatever else (e.g., untarred project tarballs) where the user will have
-   * to use a --project-root option to specify/force the project root.
-   *)
-  | Other_project
+  (* A no-VCS project is any project for which we don't support the
+     version-control system or for which it is unknown or purposefully
+     ignored with a special option.
+     A common case includes untarred project tarballs. The user may
+     have to resort to the '--project-root' option to specify/force
+     the project root where a root '.semgrepignore' may exist.
+  *)
+  | No_VCS_project
 [@@deriving show]
 
 (* In many CLI tools (e.g., semgrep), the user can specify a set of
@@ -49,7 +53,8 @@ type scanning_root_info = {
 }
 
 (*
-   Find a project root given a path in this project.
+   Find a project root given a path in this project and determine the project
+   kind.
 
    This returns the project root and the path relative to that root
    (see the scanning_root_info type above).
@@ -69,8 +74,9 @@ type scanning_root_info = {
    the root of a project.
 *)
 val find_any_project_root :
-  ?fallback_root:Rfpath.t ->
-  ?force_root:t ->
+  fallback_root:Rfpath.t option ->
+  force_novcs:bool ->
+  force_root:t option ->
   Fpath.t ->
   kind * scanning_root_info
 
diff --git a/src/analyzing/Dataflow_svalue.ml b/src/analyzing/Dataflow_svalue.ml
index 4c1c00b1539a..d8b2424f2795 100644
--- a/src/analyzing/Dataflow_svalue.ml
+++ b/src/analyzing/Dataflow_svalue.ml
@@ -144,7 +144,8 @@ let rec is_symbolic_expr expr =
   | G.New (_, _, _, args) ->
       let args = Tok.unbracket args in
       List.for_all is_symbolic_arg args
-  | _else -> false
+  | G.Record (_, fields, _) -> List.for_all is_symbolic_field fields
+  | __else__ -> false
 
 and is_symbolic_arg arg =
   match arg with
@@ -155,6 +156,18 @@ and is_symbolic_arg arg =
   | G.ArgType _ -> true
   | G.OtherArg _ -> false
 
+and is_symbolic_field field =
+  match field with
+  | G.F
+      {
+        s =
+          G.DefStmt
+            ({ name = G.EN _; _ }, G.FieldDefColon { vinit = Some expr; _ });
+        _;
+      } ->
+      is_symbolic_expr expr
+  | __else__ -> false
+
 let sym_prop eorig =
   match eorig with
   | SameAs e_gen when is_symbolic_expr e_gen -> G.Sym e_gen
diff --git a/src/core/Range.ml b/src/core/Range.ml
index a0cb684afbc5..fa505f9ffd8b 100644
--- a/src/core/Range.ml
+++ b/src/core/Range.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2020 r2c
+ * Copyright (C) 2020 Semgrep Inc.
  *
  * This library is free software; you can redistribute it and/or
  * modify it under the terms of the GNU Lesser General Public License
@@ -13,6 +13,7 @@
  * LICENSE for more details.
  *)
 open Common
+open Fpath_.Operators
 
 (*****************************************************************************)
 (* Prelude *)
@@ -80,7 +81,7 @@ let rec ( $<>$ ) r1 r2 =
 (* ex: "line1-line2" *)
 (* This is useful for generating from git diffs,
    because git diffs only include the lines *)
-let range_of_line_spec str file =
+let range_of_line_spec str (file : Fpath.t) =
   if str =~ "\\([0-9]+\\)-\\([0-9]+\\)" then (
     let a, b = Common.matched2 str in
     let line1 = s_to_i a in
@@ -89,17 +90,17 @@ let range_of_line_spec str file =
     let converters = Pos.full_converters_large file in
     let start = ref (-1) in
     let end_ = ref (-1) in
-    for i = 0 to UFile.filesize (Fpath.v file) do
+    for i = 0 to UFile.filesize file do
       let l, _ = converters.bytepos_to_linecol_fun i in
       if l =|= line1 then start := i;
       if l =|= line2 then end_ := i
     done;
     if !start <> -1 && !end_ <> -1 then { start = !start; end_ = !end_ }
-    else failwith (spf "could not find range %s in %s" str file))
+    else failwith (spf "could not find range %s in %s" str !!file))
   else failwith (spf "wrong format for linecol range spec: %s" str)
 
 (* ex: "line1:col1-line2:col2" *)
-let range_of_linecol_spec str file =
+let range_of_linecol_spec str (file : Fpath.t) =
   if str =~ "\\([0-9]+\\):\\([0-9]+\\)-\\([0-9]+\\):\\([0-9]+\\)" then (
     let a, b, c, d = Common.matched4 str in
     let line1, col1 = (s_to_i a, s_to_i b) in
@@ -108,13 +109,13 @@ let range_of_linecol_spec str file =
     let converters = Pos.full_converters_large file in
     let start = ref (-1) in
     let end_ = ref (-1) in
-    for i = 0 to UFile.filesize (Fpath.v file) do
+    for i = 0 to UFile.filesize file do
       let l, c = converters.bytepos_to_linecol_fun i in
       if (l, c) =*= (line1, col1) then start := i;
       if (l, c) =*= (line2, col2) then end_ := i
     done;
     if !start <> -1 && !end_ <> -1 then { start = !start; end_ = !end_ }
-    else failwith (spf "could not find range %s in %s" str file))
+    else failwith (spf "could not find range %s in %s" str !!file))
   else failwith (spf "wrong format for linecol range spec: %s" str)
 
 let range_of_token_locations (start_loc : Tok.location) (end_loc : Tok.location)
diff --git a/src/core/Range.mli b/src/core/Range.mli
index a29517813a7e..af0654f2f307 100644
--- a/src/core/Range.mli
+++ b/src/core/Range.mli
@@ -26,10 +26,10 @@ val ( $<$ ) : t -> t -> bool
 val ( $<>$ ) : t -> t -> bool
 
 (* start_line - end_line -> filename -> range *)
-val range_of_line_spec : string -> string (* filename *) -> t
+val range_of_line_spec : string -> Fpath.t -> t
 
 (* start_line:start_col - end_line:end_col -> filename -> range *)
-val range_of_linecol_spec : string -> string (* filename *) -> t
+val range_of_linecol_spec : string -> Fpath.t -> t
 val range_of_token_locations : Tok.location -> Tok.location -> t
 val range_of_tokens : Tok.t list -> t option
 
diff --git a/src/engine/Xpattern_matcher.ml b/src/engine/Xpattern_matcher.ml
index e384d57cd078..574076eac550 100644
--- a/src/engine/Xpattern_matcher.ml
+++ b/src/engine/Xpattern_matcher.ml
@@ -13,7 +13,6 @@
  * LICENSE for more details.
  *)
 open Common
-open Fpath_.Operators
 module MV = Metavariable
 module PM = Pattern_match
 module RP = Core_result
@@ -115,7 +114,7 @@ let () =
 
 let line_col_of_charpos (file : Fpath.t) (charpos : int) : int * int =
   let conv =
-    Common.memoized hmemo file (fun () -> Pos.full_converters_large !!file)
+    Common.memoized hmemo file (fun () -> Pos.full_converters_large file)
   in
   conv.bytepos_to_linecol_fun charpos
 
diff --git a/src/experiments/synthesizing/Pattern_from_diff.ml b/src/experiments/synthesizing/Pattern_from_diff.ml
index 3651810ff7b7..e60b8a981702 100644
--- a/src/experiments/synthesizing/Pattern_from_diff.ml
+++ b/src/experiments/synthesizing/Pattern_from_diff.ml
@@ -1,4 +1,3 @@
-open Fpath_.Operators
 module In = Input_to_core_j
 module R = Range
 module Set = Set_
@@ -103,7 +102,7 @@ let range_of_ast ast = R.range_of_tokens (AST_generic_helpers.ii_of_any ast)
 let pattern_from_diff f =
   let file = Fpath.v f.In.filename in
   let function_from_range file_ast range =
-    let r = R.range_of_line_spec range !!file in
+    let r = R.range_of_line_spec range file in
     let func = Range_to_AST.function_at_range r file_ast in
     match func with
     | None -> None
diff --git a/src/experiments/synthesizing/Synthesizer.ml b/src/experiments/synthesizing/Synthesizer.ml
index 090a41e06b46..8c07f7e75ab3 100644
--- a/src/experiments/synthesizing/Synthesizer.ml
+++ b/src/experiments/synthesizing/Synthesizer.ml
@@ -3,8 +3,9 @@ open Fpath_.Operators
 module J = JSON
 module In = Input_to_core_j
 
-let range_to_ast file lang s =
-  let r = Range.range_of_linecol_spec s !!file in
+let range_to_ast (file : Fpath.t) (lang : Lang.t) (s : string) : AST_generic.any
+    =
+  let r = Range.range_of_linecol_spec s file in
   let ast = Parse_target.parse_and_resolve_name_fail_if_partial lang file in
   let a_opt = Range_to_AST.any_at_range_all r ast in
   Naming_AST.resolve lang ast;
diff --git a/src/experiments/synthesizing/Test_synthesizing.ml b/src/experiments/synthesizing/Test_synthesizing.ml
index 657a255e8a79..2c53d01cba39 100644
--- a/src/experiments/synthesizing/Test_synthesizing.ml
+++ b/src/experiments/synthesizing/Test_synthesizing.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2020 r2c
+ * Copyright (C) 2020 Semgrep Inc.
  *
  * This library is free software; you can redistribute it and/or
  * modify it under the terms of the GNU Lesser General Public License
@@ -17,8 +17,8 @@ open Fpath_.Operators
 module J = JSON
 module In = Input_to_core_j
 
-let expr_at_range s file =
-  let r = Range.range_of_linecol_spec s !!file in
+let expr_at_range (s : string) (file : Fpath.t) : unit =
+  let r = Range.range_of_linecol_spec s file in
   UCommon.pr2_gen r;
   let ast = Parse_target.parse_program file in
   (* just to see if it works with Naming on *)
diff --git a/src/experiments/synthesizing/Unit_synthesizer.ml b/src/experiments/synthesizing/Unit_synthesizer.ml
index baccabf58019..4a74edfe354d 100644
--- a/src/experiments/synthesizing/Unit_synthesizer.ml
+++ b/src/experiments/synthesizing/Unit_synthesizer.ml
@@ -200,7 +200,7 @@ let tests =
                       (* BUG? resolve again? already done above *)
                       Naming_AST.resolve lang ast;
 
-                      let r = Range.range_of_linecol_spec range !!file in
+                      let r = Range.range_of_linecol_spec range file in
 
                       let check_pats (str, pat) =
                         try
diff --git a/src/experiments/synthesizing/Unit_synthesizer_targets.ml b/src/experiments/synthesizing/Unit_synthesizer_targets.ml
index 537b5be21f7f..ca741ddc8338 100644
--- a/src/experiments/synthesizing/Unit_synthesizer_targets.ml
+++ b/src/experiments/synthesizing/Unit_synthesizer_targets.ml
@@ -1,4 +1,5 @@
 open Common
+open Fpath_.Operators
 module R = Rule
 module E = Core_error
 module OutJ = Semgrep_output_v1_t
@@ -6,7 +7,7 @@ module OutJ = Semgrep_output_v1_t
 let t = Testo.create
 
 (* ran from the root of the semgrep repository *)
-let test_path = "tests/synthesizing/targets/"
+let test_path = Fpath.v "tests/synthesizing/targets/"
 
 (* Format: file, list of target ranges, expected pattern line. *)
 let stmt_tests =
@@ -83,10 +84,10 @@ let ranges_matched (lang : Lang.t) (file : Fpath.t) pattern : Range.t list =
   in
   List_.map extract_range matches
 
-let run_single_test file linecols expected_pattern =
+let run_single_test (file : Fpath.t) linecols expected_pattern =
   let lang, _, inferred_pattern =
     Synthesizer.generate_pattern_from_targets Rule_options.default
-      (linecols @ [ file ])
+      (linecols @ [ !!file ])
   in
   let actual_pattern =
     Pretty_print_pattern.pattern_to_string lang inferred_pattern
@@ -95,7 +96,7 @@ let run_single_test file linecols expected_pattern =
   let ranges_expected =
     List_.map (fun lcs -> Range.range_of_linecol_spec lcs file) linecols
   in
-  let ranges_actual = ranges_matched lang (Fpath.v file) inferred_pattern in
+  let ranges_actual = ranges_matched lang file inferred_pattern in
   let ranges_correct =
     List.for_all
       (fun r -> List.exists (compare_range r) ranges_actual)
@@ -116,5 +117,5 @@ let tests =
     t "pattern from targets" (fun () ->
         stmt_tests @ statement_list_tests
         |> List.iter (fun (file, linecols, expected_pattern) ->
-               run_single_test (test_path ^ file) linecols expected_pattern));
+               run_single_test (test_path / file) linecols expected_pattern));
   ]
diff --git a/src/osemgrep/cli_ci/Ci_CLI.ml b/src/osemgrep/cli_ci/Ci_CLI.ml
index 7637e8d9b2d2..a055598393db 100644
--- a/src/osemgrep/cli_ci/Ci_CLI.ml
+++ b/src/osemgrep/cli_ci/Ci_CLI.ml
@@ -209,10 +209,10 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
       max_chars_per_line max_lines_per_finding max_log_list_entries
       max_memory_mb max_target_bytes metrics num_jobs no_secrets_validation
       _no_dynamic_dependency_resolution nosem optimizations oss output pro
-      pro_intrafile pro_lang pro_path_sensitive respect_gitignore
-      rewrite_rule_ids sarif sarif_outputs scan_unknown_extensions secrets text
-      text_outputs timeout _timeout_interfileTODO timeout_threshold trace
-      trace_endpoint version_check vim vim_outputs =
+      pro_intrafile pro_lang pro_path_sensitive rewrite_rule_ids sarif
+      sarif_outputs scan_unknown_extensions secrets text text_outputs timeout
+      _timeout_interfileTODO timeout_threshold trace trace_endpoint use_git
+      version_check vim vim_outputs =
     let output_format : Output_format.t =
       Scan_CLI.output_format_conf ~text ~files_with_matches ~json ~emacs ~vim
         ~sarif ~gitlab_sast ~gitlab_secrets ~junit_xml
@@ -270,6 +270,7 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
     let targeting_conf : Find_targets.conf =
       {
         force_project_root = None;
+        force_novcs_project = false;
         exclude = exclude_;
         include_;
         baseline_commit;
@@ -277,7 +278,7 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
         max_target_bytes;
         always_select_explicit_targets = scan_unknown_extensions;
         explicit_targets = Find_targets.Explicit_targets.empty;
-        respect_gitignore;
+        respect_gitignore = use_git;
         respect_semgrepignore_files = not ignore_semgrepignore_files;
         exclude_minified_files;
       }
@@ -351,10 +352,10 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
     $ SC.o_no_secrets_validation $ SC.o_no_dynamic_dependency_resolution
     $ SC.o_nosem $ SC.o_optimizations $ SC.o_oss $ SC.o_output $ SC.o_pro
     $ SC.o_pro_intrafile $ SC.o_pro_languages $ SC.o_pro_path_sensitive
-    $ SC.o_respect_gitignore $ SC.o_rewrite_rule_ids $ SC.o_sarif
-    $ SC.o_sarif_outputs $ SC.o_scan_unknown_extensions $ SC.o_secrets
-    $ SC.o_text $ SC.o_text_outputs $ SC.o_timeout $ SC.o_timeout_interfile
-    $ SC.o_timeout_threshold $ SC.o_trace $ SC.o_trace_endpoint
+    $ SC.o_rewrite_rule_ids $ SC.o_sarif $ SC.o_sarif_outputs
+    $ SC.o_scan_unknown_extensions $ SC.o_secrets $ SC.o_text
+    $ SC.o_text_outputs $ SC.o_timeout $ SC.o_timeout_interfile
+    $ SC.o_timeout_threshold $ SC.o_trace $ SC.o_trace_endpoint $ SC.o_use_git
     $ SC.o_version_check $ SC.o_vim $ SC.o_vim_outputs)
 
 (*************************************************************************)
diff --git a/src/osemgrep/cli_ci/Ci_subcommand.ml b/src/osemgrep/cli_ci/Ci_subcommand.ml
index 4cb00ef31d0a..94bf2e2f0e61 100644
--- a/src/osemgrep/cli_ci/Ci_subcommand.ml
+++ b/src/osemgrep/cli_ci/Ci_subcommand.ml
@@ -134,6 +134,90 @@ let sanity_check_contributions (contribs : Out.contribution list) : unit =
             (List.length contribs)
             (Out.string_of_contribution x))
 
+(*****************************************************************************)
+(* Project metadata *)
+(*****************************************************************************)
+
+(* from meta.py
+ * coupling: if you add more cases below, you probably need to modify
+ * Ci_CLI.cmdline_term to pass more env there.
+ *)
+let generate_meta_from_environment caps (baseline_ref : Digestif.SHA1.t option)
+    : Project_metadata.t =
+  let extract_env term =
+    let argv = [| "empty" |] and info_ = Cmdliner.Cmd.info "" in
+    let eval term =
+      match Cmdliner.Cmd.(eval_value ~argv (v info_ term)) with
+      | Ok (`Ok env) -> env
+      | Ok `Version
+      | Ok `Help ->
+          invalid_arg "unexpected version or help"
+      | Error _e -> invalid_arg "couldn't decode environment"
+    in
+    eval term
+  in
+
+  match Sys.getenv_opt "GITHUB_ACTIONS" with
+  | Some "true" ->
+      let env = extract_env Git_metadata.env in
+      let gha_env = extract_env Github_metadata.env in
+      (new Github_metadata.meta caps baseline_ref env gha_env)#project_metadata
+  | _else ->
+      let env = extract_env Git_metadata.env in
+      (new Git_metadata.meta caps ~scan_environment:"git" ~baseline_ref env)
+        #project_metadata
+
+(* https://docs.gitlab.com/ee/ci/variables/predefined_variables.html *)
+(* match Sys.getenv_opt "GITLAB_CI" with
+   | Some "true" -> return GitlabMeta(baseline_ref)
+   | _else -> *)
+(* https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables *)
+(* match Sys.getenv_opt "CIRCLECI" with
+   | Some "true" -> return CircleCIMeta(baseline_ref)
+   | _else -> *)
+(* https://e.printstacktrace.blog/jenkins-pipeline-environment-variables-the-definitive-guide/ *)
+(* match Sys.getenv_opt "JENKINS_URL" with
+    | Some _ -> return JenkinsMeta(baseline_ref)
+    | None -> *)
+(* https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ *)
+(* match Sys.getenv_opt "BITBUCKET_BUILD_NUMBER" with
+   | Some _ -> return BitbucketMeta(baseline_ref)
+   | None -> *)
+(* https://github.com/DataDog/dd-trace-py/blob/f583fec63c4392a0784b4199b0e20931f9aae9b5/ddtrace/ext/ci.py#L90
+   picked an env var that is only defined by Azure Pipelines *)
+(* match Sys.getenv_opt "BUILD_BUILDID" with
+   | Some _ -> AzurePipelinesMeta(baseline_ref)
+   | None -> *)
+(* https://buildkite.com/docs/pipelines/environment-variables#bk-env-vars-buildkite-build-author-email *)
+(* match Sys.getenv_opt "BUILDKITE" with
+   | Some "true" -> return BuildkiteMeta(baseline_ref)
+   | _else -> *)
+(* https://docs.travis-ci.com/user/environment-variables/ *)
+(* match Sys.getenv_opt "TRAVIS" with
+   | Some "true" -> return TravisMeta(baseline_ref)
+   | _else -> return GitMeta(baseline_ref) *)
+
+(*****************************************************************************)
+(* Scan metadata *)
+(*****************************************************************************)
+
+let scan_metadata () : Out.scan_metadata =
+  let res =
+    Out.
+      {
+        cli_version = Version.version;
+        unique_id = Uuidm.v4_gen (Stdlib.Random.State.make_self_init ()) ();
+        (* TODO: should look at conf.secrets, conf.sca, conf.code, etc. *)
+        requested_products = [];
+        dry_run = false;
+        sms_scan_id = !Semgrep_envvars.v.sms_scan_id;
+      }
+  in
+  res.sms_scan_id
+  |> Option.iter (fun scan_id ->
+         Logs.debug (fun m -> m "SMS scan id: %s" scan_id));
+  res
+
 (*****************************************************************************)
 (* Scan config *)
 (*****************************************************************************)
@@ -208,23 +292,13 @@ let scan_config_and_rules_from_deployment ~dry_run
       m "  Reporting start of scan for %a"
         Fmt.(styled `Bold string)
         deployment_config.name);
-  let scan_metadata : Out.scan_metadata =
-    {
-      cli_version = Version.version;
-      unique_id = Uuidm.v4_gen (Stdlib.Random.State.make_self_init ()) ();
-      (* TODO: should look at conf.secrets, conf.sca, conf.code, etc. *)
-      requested_products = [];
-      dry_run = false;
-      (* TODO: should come from environment variable if defined *)
-      sms_scan_id = None;
-    }
-  in
+  let scan_meta : Out.scan_metadata = scan_metadata () in
   (* TODO:
       metadata_dict["is_sca_scan"] = supply_chain
       proj_config = ProjectConfig.load_all()
       metadata_dict = {**metadata_dict, **proj_config.to_dict()}
   *)
-  match Semgrep_App.start_scan ~dry_run caps prj_meta scan_metadata with
+  match Semgrep_App.start_scan ~dry_run caps prj_meta scan_meta with
   | Error msg ->
       Logs.err (fun m -> m "Could not start scan %s" msg);
       Error.exit_code_exn (Exit_code.fatal ~__LOC__)
@@ -267,69 +341,6 @@ let scan_config_and_rules_from_deployment ~dry_run
       in
       (scan_id, scan_config, [ rules_and_origins ])
 
-(*****************************************************************************)
-(* Project metadata *)
-(*****************************************************************************)
-
-(* from meta.py
- * coupling: if you add more cases below, you probably need to modify
- * Ci_CLI.cmdline_term to pass more env there.
- *)
-let generate_meta_from_environment caps (baseline_ref : Digestif.SHA1.t option)
-    : Project_metadata.t =
-  let extract_env term =
-    let argv = [| "empty" |] and info_ = Cmdliner.Cmd.info "" in
-    let eval term =
-      match Cmdliner.Cmd.(eval_value ~argv (v info_ term)) with
-      | Ok (`Ok env) -> env
-      | Ok `Version
-      | Ok `Help ->
-          invalid_arg "unexpected version or help"
-      | Error _e -> invalid_arg "couldn't decode environment"
-    in
-    eval term
-  in
-
-  match Sys.getenv_opt "GITHUB_ACTIONS" with
-  | Some "true" ->
-      let env = extract_env Git_metadata.env in
-      let gha_env = extract_env Github_metadata.env in
-      (new Github_metadata.meta caps baseline_ref env gha_env)#project_metadata
-  | _else ->
-      let env = extract_env Git_metadata.env in
-      (new Git_metadata.meta caps ~scan_environment:"git" ~baseline_ref env)
-        #project_metadata
-
-(* https://docs.gitlab.com/ee/ci/variables/predefined_variables.html *)
-(* match Sys.getenv_opt "GITLAB_CI" with
-   | Some "true" -> return GitlabMeta(baseline_ref)
-   | _else -> *)
-(* https://circleci.com/docs/2.0/env-vars/#built-in-environment-variables *)
-(* match Sys.getenv_opt "CIRCLECI" with
-   | Some "true" -> return CircleCIMeta(baseline_ref)
-   | _else -> *)
-(* https://e.printstacktrace.blog/jenkins-pipeline-environment-variables-the-definitive-guide/ *)
-(* match Sys.getenv_opt "JENKINS_URL" with
-    | Some _ -> return JenkinsMeta(baseline_ref)
-    | None -> *)
-(* https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ *)
-(* match Sys.getenv_opt "BITBUCKET_BUILD_NUMBER" with
-   | Some _ -> return BitbucketMeta(baseline_ref)
-   | None -> *)
-(* https://github.com/DataDog/dd-trace-py/blob/f583fec63c4392a0784b4199b0e20931f9aae9b5/ddtrace/ext/ci.py#L90
-   picked an env var that is only defined by Azure Pipelines *)
-(* match Sys.getenv_opt "BUILD_BUILDID" with
-   | Some _ -> AzurePipelinesMeta(baseline_ref)
-   | None -> *)
-(* https://buildkite.com/docs/pipelines/environment-variables#bk-env-vars-buildkite-build-author-email *)
-(* match Sys.getenv_opt "BUILDKITE" with
-   | Some "true" -> return BuildkiteMeta(baseline_ref)
-   | _else -> *)
-(* https://docs.travis-ci.com/user/environment-variables/ *)
-(* match Sys.getenv_opt "TRAVIS" with
-   | Some "true" -> return TravisMeta(baseline_ref)
-   | _else -> return GitMeta(baseline_ref) *)
-
 (*****************************************************************************)
 (* Partition rules *)
 (*****************************************************************************)
@@ -645,6 +656,8 @@ let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
             Some (Semgrep_output_v1_j.string_of_engine_kind engine_requested);
           (* TODO: findings_by_product *)
           findings_by_product = None;
+          (* TODO: supply_chain_stats *)
+          supply_chain_stats = None;
         };
       (* TODO:
            if self._dependency_query:
diff --git a/src/osemgrep/cli_ci/Ci_subcommand.mli b/src/osemgrep/cli_ci/Ci_subcommand.mli
index fc89b6cb9aa4..a01d3be1a6e8 100644
--- a/src/osemgrep/cli_ci/Ci_subcommand.mli
+++ b/src/osemgrep/cli_ci/Ci_subcommand.mli
@@ -23,3 +23,6 @@ val run_conf : caps -> Ci_CLI.conf -> Exit_code.t
 (* used by Test_is_blocking_Helpers.ml used itself in Test.ml *)
 val rule_is_blocking : JSON.t -> bool
 val finding_is_blocking : Semgrep_output_v1_t.cli_match -> bool
+
+(* used by Unit_ci.ml *)
+val scan_metadata : unit -> Semgrep_output_v1_t.scan_metadata
diff --git a/src/osemgrep/cli_ci/Test_ci_subcommand.ml b/src/osemgrep/cli_ci/Test_ci_subcommand.ml
new file mode 100644
index 000000000000..3c7d2765546a
--- /dev/null
+++ b/src/osemgrep/cli_ci/Test_ci_subcommand.ml
@@ -0,0 +1,113 @@
+(* Yoann Padioleau
+ *
+ * Copyright (C) 2024 Semgrep, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public License
+ * version 2.1 as published by the Free Software Foundation, with the
+ * special exception on linking described in file LICENSE.
+ *
+ * This library is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
+ * LICENSE for more details.
+ *)
+open Common
+module Out = Semgrep_output_v1_j
+
+let t = Testo.create
+
+(*****************************************************************************)
+(* Prelude *)
+(*****************************************************************************)
+(* Testing end-to-end (e2e) the ci subcommand.
+ *
+ * This is a very partial port of cli/tests/e2e/test_ci.py
+ *)
+
+(*****************************************************************************)
+(* Helpers *)
+(*****************************************************************************)
+let sms_scan_id = "SMS_1234"
+
+(* copy paste of Test_login_subcommand.ml (see this file for more info) *)
+let fake_token = "token1234"
+
+let fake_deployment =
+  {|
+  { "deployment":
+    { "id": 1234,
+      "name": "deployment1234"
+    }
+  }
+|}
+
+exception Return of string
+
+(*****************************************************************************)
+(* Tests *)
+(*****************************************************************************)
+(* LATER: the pysemgrep testing code is far more compact than this. Would
+ * be good to improve Http_mock_client to get some of the mock features
+ * of the python requests package and to have a run_semgrep() wrapper like in
+ * conftest.py.
+ *)
+let test_sms_scan_id (caps : Ci_subcommand.caps) =
+  t "sms_scan_id e2e from env to scan request field" (fun () ->
+      (* the network mock *)
+      let make_response_fn (req : Cohttp.Request.t) (body : Cohttp_lwt.Body.t) =
+        match Uri.path (Cohttp.Request.uri req) with
+        (* step1: get deployment (Semgrep_App.deployment_route) *)
+        | "/api/agent/deployments/current" ->
+            Http_mock_client.check_method `GET req.meth;
+            let response_body = fake_deployment |> Cohttp_lwt.Body.of_string in
+            Lwt.return (Http_mock_client.basic_response response_body)
+        (* step2: initiate scan (Semgrep_App.start_scan_route) *)
+        | "/api/agent/deployments/scans" ->
+            Http_mock_client.check_method `POST req.meth;
+            Logs.debug (fun m -> m "request = %s" (Dumper.dump req));
+            let%lwt body = Cohttp_lwt.Body.to_string body in
+            (* we abort the ci command so we can inspect the scan_request
+             * further below
+             *)
+            raise (Return body)
+        | url -> Alcotest.fail (spf "unexpected request: %s" url)
+      in
+      Http_mock_client.with_testing_client make_response_fn
+        (fun () ->
+          Semgrep_envvars.with_envvar "SEMGREP_MANAGED_SCAN_ID" sms_scan_id
+            (fun () ->
+              Semgrep_envvars.with_envvar "SEMGREP_APP_TOKEN" fake_token
+                (fun () ->
+                  Testutil_git.with_git_repo
+                    [ File ("empty", "") ]
+                    (fun _dir ->
+                      try
+                        let _res =
+                          Ci_subcommand.main caps
+                            [| "semgrep-ci"; "--experimental" |]
+                        in
+                        failwith
+                          "the make_response_fn should have thrown Return"
+                      with
+                      | Return s -> (
+                          let scan : Out.scan_request =
+                            Out.scan_request_of_string s
+                          in
+                          (* similar to Unit_ci.ml sms_scan_id assert *)
+                          match scan with
+                          | {
+                           scan_metadata = Some { sms_scan_id = Some str; _ };
+                           _;
+                          } ->
+                              Alcotest.(check string)
+                                "checking sms_scan_id" sms_scan_id str
+                          | _ -> failwith (spf "wrong scan request: %s" s))))))
+        ())
+
+(*****************************************************************************)
+(* Entry point *)
+(*****************************************************************************)
+
+let tests (caps : < Ci_subcommand.caps >) =
+  Testo.categorize "Osemgrep ci (e2e)" [ test_sms_scan_id caps ]
diff --git a/src/osemgrep/cli_ci/Test_ci_subcommand.mli b/src/osemgrep/cli_ci/Test_ci_subcommand.mli
new file mode 100644
index 000000000000..4a939c930f3d
--- /dev/null
+++ b/src/osemgrep/cli_ci/Test_ci_subcommand.mli
@@ -0,0 +1,1 @@
+val tests : Ci_subcommand.caps -> Testo.t list
diff --git a/src/osemgrep/cli_ci/Unit_ci.ml b/src/osemgrep/cli_ci/Unit_ci.ml
new file mode 100644
index 000000000000..1990053118bf
--- /dev/null
+++ b/src/osemgrep/cli_ci/Unit_ci.ml
@@ -0,0 +1,41 @@
+(* Yoann Padioleau
+ *
+ * Copyright (C) 2024 Semgrep, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public License
+ * version 2.1 as published by the Free Software Foundation, with the
+ * special exception on linking described in file LICENSE.
+ *
+ * This library is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
+ * LICENSE for more details.
+ *)
+
+(*****************************************************************************)
+(* Prelude *)
+(*****************************************************************************)
+
+let t = Testo.create
+
+(*****************************************************************************)
+(* Constants *)
+(*****************************************************************************)
+
+let sms_scan_id = "SMS_1234"
+
+(*****************************************************************************)
+(* Entry point *)
+(*****************************************************************************)
+
+let tests =
+  Testo.categorize "semgrep ci unit tests"
+    [
+      t "sms_scan_id from env to field" (fun () ->
+          Semgrep_envvars.with_envvar "SEMGREP_MANAGED_SCAN_ID" sms_scan_id
+            (fun () ->
+              let res = Ci_subcommand.scan_metadata () in
+              Alcotest.(check (option string))
+                "checking sms_scan_id" (Some sms_scan_id) res.sms_scan_id));
+    ]
diff --git a/src/osemgrep/cli_ci/Unit_ci.mli b/src/osemgrep/cli_ci/Unit_ci.mli
new file mode 100644
index 000000000000..f7bd04e1d452
--- /dev/null
+++ b/src/osemgrep/cli_ci/Unit_ci.mli
@@ -0,0 +1,1 @@
+val tests : Testo.t list
diff --git a/src/osemgrep/cli_ci/dune b/src/osemgrep/cli_ci/dune
index 8d90344b4293..f2f55e86cdc0 100644
--- a/src/osemgrep/cli_ci/dune
+++ b/src/osemgrep/cli_ci/dune
@@ -13,6 +13,7 @@
     osemgrep_networking
     osemgrep_cli_scan ; reusing the same flags and most of the code
     osemgrep_distributed_scan_stub
+    osemgrep_cli_login ; for test helpers
   )
  (preprocess
    (pps
diff --git a/src/osemgrep/cli_scan/Scan_CLI.ml b/src/osemgrep/cli_scan/Scan_CLI.ml
index 42ee64cbec91..fd13598a721c 100644
--- a/src/osemgrep/cli_scan/Scan_CLI.ml
+++ b/src/osemgrep/cli_scan/Scan_CLI.ml
@@ -220,22 +220,20 @@ negative value disables this filter. Defaults to %d bytes|}
   Arg.value (Arg.opt Cmdliner_.number_of_bytes_converter default info)
 
 (*
-   TODO: clarify what happens with osemgrep since its semgrepignore
-   filtering uses .gitignore and .semgrepignore files together.
-   Maybe we just wait until pysemgrep target filtering is gone?
+   TODO: deprecate this confusing option as soon as we have alternatives
+   and we're migrated to osemgrep for file targeting.
 *)
-let o_respect_gitignore : bool Term.t =
+let o_use_git : bool Term.t =
   H.negatable_flag [ "use-git-ignore" ] ~neg_options:[ "no-git-ignore" ]
     ~default:default.targeting_conf.respect_gitignore
     ~doc:
-      {|Skip files ignored by git. Scanning starts from the root
-folder specified on the Semgrep command line. Normally, if the
-scanning root is within a git repository, only the tracked files and
-the new files would be scanned. Git submodules and git- ignored files
-would normally be skipped. --no-git-ignore will disable git-aware
-filtering. Setting this flag does nothing if the scanning root is not
-in a git repository.
-|}
+      {|'--no-git-ignore' causes semgrep to not call 'git' and not consult
+        '.gitignore' files to determine which files semgrep should scan.
+        As a result of '--no-git-ignore', gitignored files and git submodules
+        will be scanned.
+        This flag has no effect if the scanning root is not
+        in a git repository.
+        '--use-git-ignore' is semgrep's default behavior.|}
 
 let o_ignore_semgrepignore_files : bool Term.t =
   let info =
@@ -569,12 +567,6 @@ let o_junit_xml : bool Term.t =
   in
   Arg.value (Arg.flag info)
 
-let o_use_osemgrep_sarif : bool Term.t =
-  let info =
-    Arg.info [ "use-osemgrep-sarif" ] ~doc:{|Output results using osemgrep.|}
-  in
-  Arg.value (Arg.flag info)
-
 (* ------------------------------------------------------------------ *)
 (* Write additional outputs *)
 (* ------------------------------------------------------------------ *)
@@ -930,14 +922,15 @@ let o_project_root : string option Term.t =
   let info =
     Arg.info [ "project-root" ]
       ~doc:
-        {|The project root for gitignore and semgrepignore purposes is
-          detected automatically from the presence of a .git/ directory in
-          the current directory or one of its parents. If not found,
-          the current directory is used as the project root. This option
-          forces a specific directory to be the project root. This is useful
-          for testing or for restoring compatibility with older semgrep
-          implementations that only looked for a .semgrepignore file
-          in the current directory. REQUIRES --experimental|}
+        {|Semgrep normally determines the type of project (git or novcs)
+          and the project root automatically. The project root is then used
+          to locate and use '.gitignore' and '.semgrepignore' files which
+          determine target files that should be ignored by semgrep.
+          This option forces the project root to be a specific folder
+          and assumes a local project without version control (novcs).
+          This option is useful to ensure the '.semgrepignore' file that
+          may exist at the project root is consulted when the scanning root
+          is not the current folder '.'. REQUIRES --experimental|}
   in
   Arg.value (Arg.opt Arg.(some string) None info)
 
@@ -945,9 +938,9 @@ let o_remote : string option Term.t =
   let info =
     Arg.info [ "remote" ]
       ~doc:
-        {|Remote will quickly checkout and scan a remote git repository of
+        {|Remote will quickly check out and scan a remote git repository of
         the format "http[s]://<WEBSITE>/.../<REPO>.git". Must be run with
-        --pro Incompatible with --project-root. Note this requires an empty
+        --pro. Incompatible with --project-root. Note this requires an empty
         CWD as this command will clone the repository into the CWD.
         REQUIRES --experimental|}
   in
@@ -1307,12 +1300,12 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
       max_memory_mb max_target_bytes metrics num_jobs
       _no_dynamic_dependency_resolution no_secrets_validation nosem
       optimizations oss output pattern pro project_root pro_intrafile pro_lang
-      pro_path_sensitive remote replacement respect_gitignore rewrite_rule_ids
-      sarif sarif_outputs scan_unknown_extensions secrets severity
-      show_supported_languages strict target_roots test test_ignore_todo text
-      text_outputs time_flag timeout _timeout_interfileTODO timeout_threshold
-      trace trace_endpoint _use_osemgrep_sarif validate version version_check
-      vim vim_outputs x_ignore_semgrepignore_files x_ls x_ls_long =
+      pro_path_sensitive remote replacement rewrite_rule_ids sarif sarif_outputs
+      scan_unknown_extensions secrets severity show_supported_languages strict
+      target_roots test test_ignore_todo text text_outputs time_flag timeout
+      _timeout_interfileTODO timeout_threshold trace trace_endpoint use_git
+      validate version version_check vim vim_outputs
+      x_ignore_semgrepignore_files x_ls x_ls_long =
     (* Print a warning if any of the internal or experimental options.
        We don't want users to start relying on these. *)
     if x_ignore_semgrepignore_files || x_ls || x_ls_long then
@@ -1402,9 +1395,12 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
       | [] -> None
       | nonempty -> Some nonempty
     in
+    let respect_gitignore = use_git in
+    let force_novcs_project = force_project_root <> None || not use_git in
     let targeting_conf : Find_targets.conf =
       {
         force_project_root;
+        force_novcs_project;
         exclude = exclude_;
         include_;
         baseline_commit;
@@ -1526,14 +1522,13 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
     $ o_num_jobs $ o_no_dynamic_dependency_resolution $ o_no_secrets_validation
     $ o_nosem $ o_optimizations $ o_oss $ o_output $ o_pattern $ o_pro
     $ o_project_root $ o_pro_intrafile $ o_pro_languages $ o_pro_path_sensitive
-    $ o_remote $ o_replacement $ o_respect_gitignore $ o_rewrite_rule_ids
-    $ o_sarif $ o_sarif_outputs $ o_scan_unknown_extensions $ o_secrets
-    $ o_severity $ o_show_supported_languages $ o_strict $ o_target_roots
-    $ o_test $ Test_CLI.o_test_ignore_todo $ o_text $ o_text_outputs $ o_time
-    $ o_timeout $ o_timeout_interfile $ o_timeout_threshold $ o_trace
-    $ o_trace_endpoint $ o_use_osemgrep_sarif $ o_validate $ o_version
-    $ o_version_check $ o_vim $ o_vim_outputs $ o_ignore_semgrepignore_files
-    $ o_ls $ o_ls_long)
+    $ o_remote $ o_replacement $ o_rewrite_rule_ids $ o_sarif $ o_sarif_outputs
+    $ o_scan_unknown_extensions $ o_secrets $ o_severity
+    $ o_show_supported_languages $ o_strict $ o_target_roots $ o_test
+    $ Test_CLI.o_test_ignore_todo $ o_text $ o_text_outputs $ o_time $ o_timeout
+    $ o_timeout_interfile $ o_timeout_threshold $ o_trace $ o_trace_endpoint
+    $ o_use_git $ o_validate $ o_version $ o_version_check $ o_vim
+    $ o_vim_outputs $ o_ignore_semgrepignore_files $ o_ls $ o_ls_long)
 
 let doc = "run semgrep rules on files"
 
diff --git a/src/osemgrep/cli_scan/Scan_CLI.mli b/src/osemgrep/cli_scan/Scan_CLI.mli
index f75a871ebdac..b960d4e636cd 100644
--- a/src/osemgrep/cli_scan/Scan_CLI.mli
+++ b/src/osemgrep/cli_scan/Scan_CLI.mli
@@ -117,7 +117,6 @@ val o_pro : bool Cmdliner.Term.t
 val o_pro_intrafile : bool Cmdliner.Term.t
 val o_pro_languages : bool Cmdliner.Term.t
 val o_pro_path_sensitive : bool Cmdliner.Term.t
-val o_respect_gitignore : bool Cmdliner.Term.t
 val o_rewrite_rule_ids : bool Cmdliner.Term.t
 val o_sarif : bool Cmdliner.Term.t
 val o_sarif_outputs : string list Cmdliner.Term.t
@@ -131,6 +130,7 @@ val o_timeout_interfile : int Cmdliner.Term.t
 val o_timeout_threshold : int Cmdliner.Term.t
 val o_trace : bool Cmdliner.Term.t
 val o_trace_endpoint : string option Cmdliner.Term.t
+val o_use_git : bool Cmdliner.Term.t
 val o_version_check : bool Cmdliner.Term.t
 val o_vim : bool Cmdliner.Term.t
 val o_vim_outputs : string list Cmdliner.Term.t
diff --git a/src/osemgrep/configuring/Semgrep_envvars.ml b/src/osemgrep/configuring/Semgrep_envvars.ml
index 94968bf13ec7..4c2057473549 100644
--- a/src/osemgrep/configuring/Semgrep_envvars.ml
+++ b/src/osemgrep/configuring/Semgrep_envvars.ml
@@ -101,6 +101,7 @@ type t = {
   is_ci : bool;
   in_docker : bool;
   in_gh_action : bool;
+  sms_scan_id : string option;
   (* deprecated *)
   in_agent : bool;
   min_fetch_depth : int;
@@ -168,6 +169,7 @@ let of_current_sys_env () : t =
     is_ci = in_env "CI";
     in_docker = in_env "SEMGREP_IN_DOCKER";
     in_gh_action = in_env "GITHUB_WORKSPACE";
+    sms_scan_id = env_opt "SEMGREP_MANAGED_SCAN_ID";
     in_agent = in_env "SEMGREP_AGENT";
     min_fetch_depth = env_or int_of_string "SEMGREP_GHA_MIN_FETCH_DEPTH" 0;
     mock_using_registry = in_env "MOCK_USING_REGISTRY";
diff --git a/src/osemgrep/configuring/Semgrep_envvars.mli b/src/osemgrep/configuring/Semgrep_envvars.mli
index 5d7c32fb5d27..5a98c26af186 100644
--- a/src/osemgrep/configuring/Semgrep_envvars.mli
+++ b/src/osemgrep/configuring/Semgrep_envvars.mli
@@ -43,6 +43,10 @@ type t = {
   in_docker : bool;
   (* $GITHUB_WORKSPACE *)
   in_gh_action : bool;
+  (* Unique identifier for the managed_scan in semgrep-app
+   * $SEMGREP_MANAGED_SCAN_ID
+   *)
+  sms_scan_id : string option;
   (* $SEMGREP_AGENT (deprecated) *)
   in_agent : bool;
   (* $SEMGREP_xxx *)
diff --git a/src/osemgrep/language_server/Unit_LS.ml b/src/osemgrep/language_server/Unit_LS.ml
index c3b2d0bdceac..486cb41f85d5 100644
--- a/src/osemgrep/language_server/Unit_LS.ml
+++ b/src/osemgrep/language_server/Unit_LS.ml
@@ -263,9 +263,11 @@ let ci_tests caps =
       let body =
         match uri with
         | "/api/agent/deployments/scans/config" ->
-            Http_mock_client.body_of_file "./tests/ls/ci/rule_conf_resp.json"
+            Http_mock_client.body_of_file
+              (Fpath.v "./tests/ls/ci/rule_conf_resp.json")
         | "/api/agent/deployments/current" ->
-            Http_mock_client.body_of_file "./tests/login/ok_response.json"
+            Http_mock_client.body_of_file
+              (Fpath.v "./tests/login/ok_response.json")
         | _ ->
             failwith (Printf.sprintf "Unexpected request to %s in CI tests" uri)
       in
diff --git a/src/osemgrep/language_server/server/User_settings.ml b/src/osemgrep/language_server/server/User_settings.ml
index 36e5dcd6dd94..570a47cc327f 100644
--- a/src/osemgrep/language_server/server/User_settings.ml
+++ b/src/osemgrep/language_server/server/User_settings.ml
@@ -50,17 +50,14 @@ let find_targets_conf_of_t settings : Find_targets.conf =
     if settings.include_ <> [] then Some settings.include_ else None
   in
   {
+    Find_targets.default_conf with
     exclude = settings.exclude;
     include_;
     max_target_bytes = settings.max_target_bytes;
-    respect_gitignore = true;
-    respect_semgrepignore_files = true;
-    baseline_commit = None;
+    (* TODO: explain or use the default value of default_conf.diff_depth *)
     diff_depth = 0;
-    always_select_explicit_targets = false;
-    explicit_targets = Find_targets.Explicit_targets.empty;
-    force_project_root = None;
-    (* If you're editing minified files then ??? *)
+    (* If you're editing minified files then ???
+       TODO: explain or use the same default as in default_conf. *)
     exclude_minified_files = true;
   }
 
diff --git a/src/osemgrep/networking/Semgrep_App.ml b/src/osemgrep/networking/Semgrep_App.ml
index cbc71672f34b..8530ddbd75d5 100644
--- a/src/osemgrep/networking/Semgrep_App.ml
+++ b/src/osemgrep/networking/Semgrep_App.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2023 Semgrep Inc.
+ * Copyright (C) 2023-2024 Semgrep Inc.
  *
  * This library is free software; you can redistribute it and/or
  * modify it under the terms of the GNU Lesser General Public License
@@ -13,7 +13,7 @@
  * LICENSE for more details.
  *)
 open Common
-module OutJ = Semgrep_output_v1_j
+module Out = Semgrep_output_v1_j
 
 (*****************************************************************************)
 (* Prelude *)
@@ -63,7 +63,7 @@ let complete_route scan_id = "/api/agent/scans/" ^ scan_id ^ "/complete"
 (* Returns the scan config if the token is valid, otherwise None *)
 let get_scan_config_from_token_async
     (caps : < Auth.cap_token ; Cap.network ; .. >) :
-    OutJ.scan_config option Lwt.t =
+    Out.scan_config option Lwt.t =
   let url = Uri.with_path !Semgrep_envvars.v.semgrep_url scan_config_route in
   let headers =
     [
@@ -75,7 +75,7 @@ let get_scan_config_from_token_async
   let scan_config_opt =
     match response with
     | Ok { body = Ok body; _ } -> (
-        try Some (OutJ.scan_config_of_string body) with
+        try Some (Out.scan_config_of_string body) with
         | Yojson.Json_error msg ->
             Logs.err (fun m ->
                 m "failed to parse body as scan_config %s: %s" msg body);
@@ -122,13 +122,12 @@ let extract_scan_id (data : string) : (scan_id, string) result =
 
 (* the server reply when POST to "/api/agent/scans/<scan_id>/results"  *)
 let extract_errors (data : string) : string list =
-  match OutJ.ci_scan_results_response_of_string data with
+  match Out.ci_scan_results_response_of_string data with
   | { errors; task_id = _ } as response ->
       Logs.debug (fun m ->
-          m "results response = %s"
-            (OutJ.show_ci_scan_results_response response));
+          m "results response = %s" (Out.show_ci_scan_results_response response));
       errors
-      |> List_.map (fun (x : OutJ.ci_scan_results_response_error) -> x.message)
+      |> List_.map (fun (x : Out.ci_scan_results_response_error) -> x.message)
   | exception exn ->
       Logs.err (fun m ->
           m "Failed to decode server reply as json %s: %s"
@@ -138,11 +137,11 @@ let extract_errors (data : string) : string list =
 (* the server reply when POST to "/api/agent/scans/<scan_id>/complete" *)
 let extract_block_override (data : string) : (app_block_override, string) result
     =
-  match OutJ.ci_scan_complete_response_of_string data with
+  match Out.ci_scan_complete_response_of_string data with
   | { success = _; app_block_override; app_block_reason } as response ->
       Logs.debug (fun m ->
           m "complete response = %s"
-            (OutJ.show_ci_scan_complete_response response));
+            (Out.show_ci_scan_complete_response response));
       if app_block_override then Ok (Some app_block_reason)
         (* TODO? can we have a app_block_reason set when override is false? *)
       else Ok None
@@ -156,7 +155,7 @@ let extract_block_override (data : string) : (app_block_override, string) result
 (*****************************************************************************)
 
 (* Returns the deployment config if the token is valid, otherwise None *)
-let get_deployment_from_token_async caps : OutJ.deployment_config option Lwt.t =
+let get_deployment_from_token_async caps : Out.deployment_config option Lwt.t =
   let headers =
     [
       ("User-Agent", Fmt.str "Semgrep/%s" Version.version);
@@ -168,7 +167,7 @@ let get_deployment_from_token_async caps : OutJ.deployment_config option Lwt.t =
   let deployment_opt =
     match response with
     | Ok { body = Ok body; _ } ->
-        let x = OutJ.deployment_response_of_string body in
+        let x = Out.deployment_response_of_string body in
         Some x.deployment
     | Ok { body = Error msg; code; _ } ->
         Logs.err (fun m ->
@@ -191,7 +190,7 @@ let get_deployment_from_token token =
 
 (* TODO: pass project_config *)
 let start_scan_async ~dry_run caps (prj_meta : Project_metadata.t)
-    (scan_meta : OutJ.scan_metadata) : (scan_id, string) result Lwt.t =
+    (scan_meta : Out.scan_metadata) : (scan_id, string) result Lwt.t =
   if dry_run then (
     Logs.app (fun m -> m "Would have sent POST request to create scan");
     Lwt.return_ok "")
@@ -212,9 +211,9 @@ let start_scan_async ~dry_run caps (prj_meta : Project_metadata.t)
     (* TODO: should concatenate with raw_json project_config *)
     let meta =
       (* ugly: would be good for ATDgen to generate also a json_of_xxx *)
-      prj_meta |> OutJ.string_of_project_metadata |> Yojson.Basic.from_string
+      prj_meta |> Out.string_of_project_metadata |> Yojson.Basic.from_string
     in
-    let request : OutJ.scan_request =
+    let request : Out.scan_request =
       {
         meta;
         scan_metadata = Some scan_meta;
@@ -223,7 +222,7 @@ let start_scan_async ~dry_run caps (prj_meta : Project_metadata.t)
         project_config = None;
       }
     in
-    let body = OutJ.string_of_scan_request request in
+    let body = Out.string_of_scan_request request in
     let pretty_body =
       body |> Yojson.Basic.from_string |> Yojson.Basic.pretty_to_string
     in
@@ -324,12 +323,12 @@ let fetch_scan_config_string ~dry_run ~sca ~full_scan ~repository caps :
   Lwt.return conf_string
 
 let fetch_scan_config_async ~dry_run ~sca ~full_scan ~repository caps :
-    (OutJ.scan_config, string) result Lwt.t =
+    (Out.scan_config, string) result Lwt.t =
   let%lwt scan_config_string =
     fetch_scan_config_string ~dry_run ~sca ~full_scan ~repository caps
   in
   let scan_config_opt =
-    Result.bind scan_config_string (fun c -> Ok (OutJ.scan_config_of_string c))
+    Result.bind scan_config_string (fun c -> Ok (Out.scan_config_of_string c))
   in
   Lwt.return scan_config_opt
 
@@ -344,8 +343,8 @@ let fetch_scan_config ~dry_run ~sca ~full_scan ~repository caps =
 (* python: was called report_findings *)
 let upload_findings_async ~dry_run ~scan_id ~results ~complete caps :
     (app_block_override, string) result Lwt.t =
-  let results = OutJ.string_of_ci_scan_results results in
-  let complete = OutJ.string_of_ci_scan_complete complete in
+  let results = Out.string_of_ci_scan_results results in
+  let complete = Out.string_of_ci_scan_complete complete in
   if dry_run then (
     Logs.app (fun m ->
         m "Would have sent findings and ignores blob: %s" results);
@@ -447,11 +446,11 @@ let report_failure_async ~dry_run ~scan_id caps (exit_code : Exit_code.t) :
       Uri.with_path !Semgrep_envvars.v.semgrep_url
         ("/api/agent/scans/" ^ scan_id ^ "/error")
     in
-    let failure : OutJ.ci_scan_failure =
+    let failure : Out.ci_scan_failure =
       { exit_code = int_code; (* TODO *)
                               stderr = "" }
     in
-    let body = OutJ.string_of_ci_scan_failure failure in
+    let body = Out.string_of_ci_scan_failure failure in
     match%lwt Http_helpers.post ~body ~headers caps#network url with
     | Ok { body = Ok _; _ } -> Lwt.return_unit
     | Ok { body = Error msg; code; _ } ->
diff --git a/src/osemgrep/networking/Unit_Login.ml b/src/osemgrep/networking/Unit_Login.ml
index b12c6ceb1de3..99016cc20ccf 100644
--- a/src/osemgrep/networking/Unit_Login.ml
+++ b/src/osemgrep/networking/Unit_Login.ml
@@ -51,12 +51,13 @@ let with_mock_normal_responses =
     | "/api/agent/tokens/requests" ->
         let%lwt () =
           Http_mock_client.check_body body
-            Http_mock_client.(
-              body_of_file ~trim:true "./tests/login/fetch_body.json")
+            (Http_mock_client.body_of_file ~trim:true
+               (Fpath.v "./tests/login/fetch_body.json"))
         in
         Lwt.return
           (Http_mock_client.basic_response ~status:200
-             Http_mock_client.(body_of_file "./tests/login/token_response.json"))
+             (Http_mock_client.body_of_file
+                (Fpath.v "./tests/login/token_response.json")))
     | _ -> failwith ("Unexpected path: " ^ Uri.path uri)
   in
   Http_mock_client.with_testing_client make_fn
diff --git a/src/osemgrep/reporting/Exposure.ml b/src/osemgrep/reporting/Exposure.ml
new file mode 100644
index 000000000000..27e581e7bf30
--- /dev/null
+++ b/src/osemgrep/reporting/Exposure.ml
@@ -0,0 +1,20 @@
+open Common
+module J = JSON
+module Out = Semgrep_output_v1_t
+
+type t = Reachable | Undetermined | Unreachable
+
+let string_of = function
+  | Reachable -> "reachable"
+  | Undetermined -> "undetermined"
+  | Unreachable -> "unreachable"
+
+(* python: from rule_match.py exposure_type() *)
+let of_cli_match_opt (cli_match : Out.cli_match) : t option =
+  let* { reachable; _ } = cli_match.extra.sca_info in
+  let metadata = JSON.from_yojson cli_match.extra.metadata in
+  match JSON.member "sca-kind" metadata with
+  | Some (J.String "upgrade-only") -> Some Reachable
+  | Some (J.String "legacy") -> Some Undetermined
+  (* TODO: stricter: raise error if Some else_json *)
+  | _ -> if reachable then Some Reachable else Some Unreachable
diff --git a/src/osemgrep/reporting/Exposure.mli b/src/osemgrep/reporting/Exposure.mli
new file mode 100644
index 000000000000..c51f3ddc0be5
--- /dev/null
+++ b/src/osemgrep/reporting/Exposure.mli
@@ -0,0 +1,4 @@
+type t = Reachable | Undetermined | Unreachable
+
+val string_of : t -> string
+val of_cli_match_opt : Semgrep_output_v1_t.cli_match -> t option
diff --git a/src/osemgrep/reporting/Gitlab_output.ml b/src/osemgrep/reporting/Gitlab_output.ml
index 43c601de14f0..933e10fd2518 100644
--- a/src/osemgrep/reporting/Gitlab_output.ml
+++ b/src/osemgrep/reporting/Gitlab_output.ml
@@ -35,23 +35,6 @@ let to_gitlab_severity = function
   | `Inventory ->
       "Unknown"
 
-type exposure = Reachable | Undetermined | Unreachable
-
-let string_of_exposure = function
-  | Reachable -> "reachable"
-  | Undetermined -> "undetermined"
-  | Unreachable -> "unreachable"
-
-(* python: from rule_match.py exposure_type() *)
-let exposure_opt (cli_match : Out.cli_match) : exposure option =
-  let* { reachable; _ } = cli_match.extra.sca_info in
-  let metadata = JSON.from_yojson cli_match.extra.metadata in
-  match JSON.member "sca-kind" metadata with
-  | Some (J.String "upgrade-only") -> Some Reachable
-  | Some (J.String "legacy") -> Some Undetermined
-  (* TODO: stricter: raise error if Some else_json *)
-  | _ -> if reachable then Some Reachable else Some Unreachable
-
 let format_cli_match (cli_match : Out.cli_match) : (string * JSON.yojson) list =
   let metadata = JSON.from_yojson cli_match.extra.metadata in
   let source =
@@ -89,7 +72,7 @@ let format_cli_match (cli_match : Out.cli_match) : (string * JSON.yojson) list =
         ([], [])
   in
   let exposure_details, exposure_flags =
-    match exposure_opt cli_match with
+    match Exposure.of_cli_match_opt cli_match with
     | None -> ([], [])
     | Some exposure ->
         ( [
@@ -98,7 +81,7 @@ let format_cli_match (cli_match : Out.cli_match) : (string * JSON.yojson) list =
                 [
                   ("type", `String "text");
                   ("name", `String "exposure");
-                  ("value", `String (string_of_exposure exposure));
+                  ("value", `String (Exposure.string_of exposure));
                 ] );
           ],
           match exposure with
diff --git a/src/osemgrep/reporting/Output_format.ml b/src/osemgrep/reporting/Output_format.ml
index 1808c986f50e..e8795dfb5d3d 100644
--- a/src/osemgrep/reporting/Output_format.ml
+++ b/src/osemgrep/reporting/Output_format.ml
@@ -19,8 +19,11 @@ type t = Semgrep_output_v1_t.output_format =
   | Incremental
 [@@deriving show]
 
-let keep_ignores (format : t) =
+let keep_ignores (format : t) : bool =
   match format with
+  (* SARIF output includes ignored findings, but labels them as suppressed.
+   * https://docs.oasis-open.org/sarif/sarif/v2.1.0/csprd01/sarif-v2.1.0-csprd01.html#_Toc10541099
+   *)
   | Sarif -> true
   | Text
   | Json
diff --git a/src/osemgrep/reporting/Sarif_output.ml b/src/osemgrep/reporting/Sarif_output.ml
index cfcce059ddff..8a134a1830dc 100644
--- a/src/osemgrep/reporting/Sarif_output.ml
+++ b/src/osemgrep/reporting/Sarif_output.ml
@@ -7,15 +7,25 @@ module Sarif = Sarif.Sarif_v_2_1_0_v
 (*****************************************************************************)
 (* Formats the CLI output to the SARIF format using the sarif OPAM package.
  *
- * The sarif spec is available at:
- * https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html
+ * Originally written based on:
+ *  - https://help.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/about-sarif-support-for-code-scanning
+ *   - Which links to this schema:
+ *     https://github.com/oasis-tcs/sarif-spec/blob/master/Schemata/sarif-schema-2.1.0.json
+ *
+ * Full spec:
+ *  https://docs.oasis-open.org/sarif/sarif/v2.1.0/sarif-v2.1.0.html (2023)
+ *
+ * Ported from formatters/sarif.py
  *)
 
 (*****************************************************************************)
 (* Helpers *)
 (*****************************************************************************)
 
-(* See the "level" property in the spec *)
+(* SARIF v2.1.0-compliant severity string.
+ * See the "level" property in the spec
+ * See https://github.com/oasis-tcs/sarif-spec/blob/a6473580/Schemata/sarif-schema-2.1.0.json#L1566
+ *)
 let severity_of_severity sev : Sarif.notification_level =
   match sev with
   | `Info
@@ -50,6 +60,7 @@ let region ?message ?snippet (start : Out.position) (end_ : Out.position) =
   Sarif.create_region ~start_line ~start_column ~end_line ~end_column ?message
     ?snippet ()
 
+(* Tags to display on SARIF-compliant UIs, such as GitHub security scans. *)
 let tags_of_metadata metadata =
   (* XXX: Tags likely have to be strings, but what do we do with non-string json?! *)
   let best_effort_string = function
@@ -360,12 +371,17 @@ let results show_dataflow_traces (cli_output : Out.cli_output) =
     let code_flows =
       if show_dataflow_traces then sarif_codeflow cli_match else None
     in
+    let properties =
+      match Exposure.of_cli_match_opt cli_match with
+      | None -> []
+      | Some exposure -> [ ("exposure", `String (Exposure.string_of exposure)) ]
+    in
     Sarif.create_result
       ~rule_id:(Rule_ID.to_string cli_match.check_id)
       ~message:(message cli_match.extra.message)
       ~locations:[ location ]
       ~fingerprints:[ ("matchBasedId/v1", cli_match.extra.fingerprint) ]
-      ~properties:[] ?code_flows ?fixes ?suppressions ()
+      ~properties ?code_flows ?fixes ?suppressions ()
   in
   List_.map result cli_output.results
 
diff --git a/src/reporting/Nosemgrep.ml b/src/reporting/Nosemgrep.ml
index 5f33606f5d7c..4e48e502ba1d 100644
--- a/src/reporting/Nosemgrep.ml
+++ b/src/reporting/Nosemgrep.ml
@@ -124,7 +124,7 @@ let rule_match_nosem (pm : Pattern_match.t) : bool * Core_error.t list =
     (* bugfix: This is only needed in relatively rare cases, and it's costly to
      * compute both in time and memory. Making it lazy avoids this computation
      * when it's not needed. *)
-    lazy (Pos.full_converters_large !!path).linecol_to_bytepos_fun
+    lazy (Pos.full_converters_large path).linecol_to_bytepos_fun
   in
 
   let previous_line, line =
diff --git a/src/targeting/Find_targets.ml b/src/targeting/Find_targets.ml
index f27fd1372b8c..75ddc76fea08 100644
--- a/src/targeting/Find_targets.ml
+++ b/src/targeting/Find_targets.ml
@@ -135,6 +135,7 @@ type conf = {
   (* osemgrep-only: option
      (see Git_project.find_any_project_root and the force_root parameter) *)
   force_project_root : project_root option;
+  force_novcs_project : bool;
   (* osemgrep-only option, exclude scanning minified files, default false *)
   exclude_minified_files : bool;
   (* TODO? remove it? This is now done in Diff_scan.ml instead? *)
@@ -150,6 +151,7 @@ type conf = {
 let default_conf : conf =
   {
     force_project_root = None;
+    force_novcs_project = false;
     exclude = [];
     include_ = None;
     (* Must be kept in sync w/ pysemgrep.
@@ -517,19 +519,21 @@ let git_list_tracked_files (project_roots : Project.roots) : Fppath_set.t option
 
    This is the complement of git_list_tracked_files (except for '.git/').
 *)
-let git_list_untracked_files (project_roots : Project.roots) :
-    Fppath_set.t option =
-  git_list_files ~exclude_standard:true [ Others ] project_roots
+let git_list_untracked_files ~respect_gitignore (project_roots : Project.roots)
+    : Fppath_set.t option =
+  git_list_files ~exclude_standard:respect_gitignore [ Others ] project_roots
 
 (*************************************************************************)
 (* Grouping *)
 (*************************************************************************)
 
-let scanning_root_by_project (force_root : Project.t option)
-    (scanning_root : Scanning_root.t) : Project.t * Fppath.t =
+let scanning_root_by_project ~(force_root : Project.t option)
+    ~(force_novcs : bool) (scanning_root : Scanning_root.t) :
+    Project.t * Fppath.t =
   let scanning_root_fpath = Scanning_root.to_fpath scanning_root in
   let kind, scanning_root_info =
-    Project.find_any_project_root ?force_root scanning_root_fpath
+    Project.find_any_project_root ~fallback_root:None ~force_novcs ~force_root
+      scanning_root_fpath
   in
   let project : Project.t = { kind; root = scanning_root_info.project_root } in
   let path : Fppath.t =
@@ -587,7 +591,9 @@ let group_scanning_roots_by_files_and_projects (conf : conf)
   in
   let project_roots =
     dir_scanning_roots
-    |> List_.map (scanning_root_by_project force_root)
+    |> List_.map
+         (scanning_root_by_project ~force_novcs:conf.force_novcs_project
+            ~force_root)
     (* Using a realpath (physical path) in Project.t ensures we group
        correctly even if the scanning_roots went through different symlink paths.
     *)
@@ -622,7 +628,7 @@ let setup_path_filters conf (project_roots : Project.roots) :
     | Mercurial_project
     | Subversion_project
     | Darcs_project
-    | Other_project ->
+    | No_VCS_project ->
         {
           use_gitignore_files = false;
           use_semgrepignore_files = conf.respect_semgrepignore_files;
@@ -645,7 +651,7 @@ let setup_path_filters conf (project_roots : Project.roots) :
    *)
   let semgrepignore_filter =
     Semgrepignore.create ~cli_patterns:conf.exclude
-      ~builtin_semgrepignore:Semgrep_scan_legacy ~exclusion_mechanism
+      ~default_semgrepignore_patterns:Semgrep_scan_legacy ~exclusion_mechanism
       ~project_root:(Rfpath.to_fpath project_root)
       ()
   in
@@ -717,7 +723,10 @@ let get_targets_for_project conf (project_roots : Project.roots) =
   (* Obtain the list of files from git if possible because it does it
      faster than what we can do by scanning the filesystem: *)
   let git_tracked = git_list_tracked_files project_roots in
-  let git_untracked = git_list_untracked_files project_roots in
+  let git_untracked =
+    git_list_untracked_files ~respect_gitignore:conf.respect_gitignore
+      project_roots
+  in
   let selected_targets, skipped_targets =
     match (git_tracked, git_untracked) with
     (* Git only *)
diff --git a/src/targeting/Find_targets.mli b/src/targeting/Find_targets.mli
index 96f320bb4ad6..062c6ad80393 100644
--- a/src/targeting/Find_targets.mli
+++ b/src/targeting/Find_targets.mli
@@ -64,6 +64,11 @@ type conf = {
   explicit_targets : Explicit_targets.t;
   (* osemgrep-only: option (see Git_project.ml and the force_root parameter) *)
   force_project_root : project_root option;
+  (* force the project to not use git or other VCS to list files.
+     The special folders '.git', '.hg', etc. may still be used to guess the
+     project root. To impose the project root as well, set
+     'force_project_root = true'. *)
+  force_novcs_project : bool;
   (* osemgrep-only: exclude scanning large files based on
       max_target_bytes, default true *)
   exclude_minified_files : bool;
diff --git a/src/targeting/Find_targets_old.ml b/src/targeting/Find_targets_old.ml
index 34ba7bb28e50..67069400daf9 100644
--- a/src/targeting/Find_targets_old.ml
+++ b/src/targeting/Find_targets_old.ml
@@ -113,7 +113,8 @@ let group_roots_by_project conf (paths : Scanning_root.t list) =
            let ( kind,
                  ({ project_root = root; inproject_path = git_path } :
                    Project.scanning_root_info) ) =
-             Project.find_any_project_root ?force_root
+             Project.find_any_project_root ~fallback_root:None
+               ~force_novcs:conf.force_novcs_project ~force_root
                (Scanning_root.to_fpath path)
            in
            ((kind, root), Ppath.to_fpath ~root:(Rfpath.to_fpath root) git_path))
@@ -125,7 +126,7 @@ let group_roots_by_project conf (paths : Scanning_root.t list) =
                  : Project.scanning_root_info) =
              Project.force_project_root (Scanning_root.to_fpath path)
            in
-           ( (Project.Other_project, root),
+           ( (Project.No_VCS_project, root),
              Ppath.to_fpath ~root:(Rfpath.to_fpath root) git_path ))
 
 (*************************************************************************)
@@ -260,12 +261,12 @@ let get_targets conf (scanning_roots : Scanning_root.t list) =
            | Mercurial_project
            | Subversion_project
            | Darcs_project
-           | Other_project ->
+           | No_VCS_project ->
                { use_gitignore_files = false; use_semgrepignore_files = true }
          in
          let ign =
            Semgrepignore.create ~cli_patterns:conf.exclude
-             ~builtin_semgrepignore:Empty ~exclusion_mechanism
+             ~default_semgrepignore_patterns:Empty ~exclusion_mechanism
              ~project_root:(Rfpath.to_fpath project_root)
              ()
          in
diff --git a/src/targeting/Semgrepignore.ml b/src/targeting/Semgrepignore.ml
index d924a89e1383..2fdfe1ae1264 100644
--- a/src/targeting/Semgrepignore.ml
+++ b/src/targeting/Semgrepignore.ml
@@ -35,7 +35,7 @@
      (we need to answer this to figure out the consequences of the migration
      plan)
 *)
-type builtin_semgrepignore = Empty | Semgrep_scan_legacy
+type default_semgrepignore_patterns = Empty | Semgrep_scan_legacy
 
 (*
    TODO: Preprocess a file to expand ':include' directives before parsing it
@@ -49,7 +49,8 @@ type exclusion_mechanism = {
 }
 
 (*
-   The legacy built-in semgrepignore.
+   The default semgrepignore used when no .semgrepignore exists
+   at the project root (osemgrep) or in the current folder (legacy pysemgrep).
 
    It was copied from templates/.semgrepignore in the Python source.
 
@@ -57,7 +58,7 @@ type exclusion_mechanism = {
    If you modify this file, also modify:
    OSS/cli/src/semgrep/templates/.semgrepignore
 *)
-let builtin_semgrepignore_for_semgrep_scan =
+let default_semgrepignore_for_semgrep_scan =
   {|
 # Git administrative folder or file
 .git
@@ -92,15 +93,15 @@ let semgrepignore_files = ("semgrepignore", ".semgrepignore")
 
 let contents_of_builtin_semgrepignore = function
   | Empty -> ""
-  | Semgrep_scan_legacy -> builtin_semgrepignore_for_semgrep_scan
+  | Semgrep_scan_legacy -> default_semgrepignore_for_semgrep_scan
 
-let create ?(cli_patterns = []) ~builtin_semgrepignore ~exclusion_mechanism
-    ~project_root () =
+let create ?(cli_patterns = []) ~default_semgrepignore_patterns
+    ~exclusion_mechanism ~project_root () =
   let root_anchor = Glob.Pattern.root_pattern in
-  let builtin_patterns =
-    Parse_gitignore.from_string ~name:"built-in semgrepignore patterns"
-      ~source_kind:"built-in" ~anchor:root_anchor
-      (contents_of_builtin_semgrepignore builtin_semgrepignore)
+  let default_patterns =
+    Parse_gitignore.from_string ~name:"default semgrepignore patterns"
+      ~source_kind:"default" ~anchor:root_anchor
+      (contents_of_builtin_semgrepignore default_semgrepignore_patterns)
   in
   let cli_patterns =
     List.concat_map
@@ -108,11 +109,11 @@ let create ?(cli_patterns = []) ~builtin_semgrepignore ~exclusion_mechanism
          ~source_kind:"exclude" ~anchor:root_anchor)
       cli_patterns
   in
-  let builtin_level : Gitignore.level =
+  let default_semgrepignore_file_level : Gitignore.level =
     {
-      level_kind = "built-in semgrepignore patterns";
+      level_kind = "default semgrepignore patterns";
       source_name = "<built-in>";
-      patterns = builtin_patterns;
+      patterns = default_patterns;
     }
   in
   let cli_level : Gitignore.level =
@@ -137,27 +138,25 @@ let create ?(cli_patterns = []) ~builtin_semgrepignore ~exclusion_mechanism
      empty root '.semgrepignore' file.
   *)
   let root_semgrepignore_exists =
-    let gitignore_cache =
-      Gitignore_cache.create ~gitignore_filenames:[ semgrepignore_files ]
-        ~project_root ()
-    in
-    Gitignore_cache.load gitignore_cache Ppath.root |> Option.is_some
+    let root_dir = Ppath.to_fpath ~root:project_root Ppath.root in
+    let semgrepignore_path = Fpath.add_seg root_dir ".semgrepignore" in
+    Sys.file_exists (Fpath.to_string semgrepignore_path)
   in
 
   (*
-     This condition determines whether the built-in semgrepignore rules
+     This condition determines whether the default semgrepignore rules
      should apply.
   *)
-  let root_semgrepignore_exists_and_is_consulted =
-    root_semgrepignore_exists && exclusion_mechanism.use_semgrepignore_files
+  let use_default_semgrepignore =
+    exclusion_mechanism.use_semgrepignore_files && not root_semgrepignore_exists
   in
 
   let higher_priority_levels =
-    if root_semgrepignore_exists_and_is_consulted then [ cli_level ]
-    else
+    if use_default_semgrepignore then
       (* use the built-in semgrepignore rules in the absence of a root
          '.semgrepignore' file *)
-      [ builtin_level; cli_level ]
+      [ default_semgrepignore_file_level; cli_level ]
+    else [ cli_level ]
   in
   let gitignore_filter =
     Gitignore_filter.create ~higher_priority_levels
diff --git a/src/targeting/Semgrepignore.mli b/src/targeting/Semgrepignore.mli
index a4b7aa8827f9..ffc41ef864d2 100644
--- a/src/targeting/Semgrepignore.mli
+++ b/src/targeting/Semgrepignore.mli
@@ -13,11 +13,8 @@
    or empty when scanning for secrets.
 
    The 'Empty' case is useful for testing.
-
-   TODO: combine the built-in semgrepignore files into one once
-   we have an extended syntax with sections.
 *)
-type builtin_semgrepignore = Empty | Semgrep_scan_legacy
+type default_semgrepignore_patterns = Empty | Semgrep_scan_legacy
 
 type exclusion_mechanism = {
   use_gitignore_files : bool;
@@ -36,7 +33,7 @@ type exclusion_mechanism = {
 *)
 val create :
   ?cli_patterns:string list ->
-  builtin_semgrepignore:builtin_semgrepignore ->
+  default_semgrepignore_patterns:default_semgrepignore_patterns ->
   exclusion_mechanism:exclusion_mechanism ->
   project_root:Fpath.t ->
   unit ->
diff --git a/src/targeting/Unit_semgrepignore.ml b/src/targeting/Unit_semgrepignore.ml
index f3509402b81f..9262a233ca93 100644
--- a/src/targeting/Unit_semgrepignore.ml
+++ b/src/targeting/Unit_semgrepignore.ml
@@ -31,7 +31,7 @@ let test_filter ?excludes:cli_patterns (files : F.t list) selection () =
       assert (files2 = files);
       printf "--- Filtered files ---\n";
       let filter =
-        Semgrepignore.create ?cli_patterns ~builtin_semgrepignore:Empty
+        Semgrepignore.create ?cli_patterns ~default_semgrepignore_patterns:Empty
           ~exclusion_mechanism:
             { use_gitignore_files = true; use_semgrepignore_files = true }
           ~project_root:root ()


Test patch:
diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py
index 01579ba30cb1..ec0edc01522a 100644
--- a/cli/tests/conftest.py
+++ b/cli/tests/conftest.py
@@ -73,9 +73,18 @@ def pytest_addoption(parser: pytest.Parser) -> None:
         help="Filter test execution to tests that use pytest-snapshot",
     )
 
+    parser.addoption(
+        "--run-lockfileless",
+        action="store_true",
+        default=False,
+        help="Include tests marked as requiring lockfileless environment dependencies.",
+    )
+
 
 # ???
-def pytest_collection_modifyitems(items: pytest.Item, config: pytest.Config) -> None:
+def pytest_collection_modifyitems(
+    items: List[pytest.Item], config: pytest.Config
+) -> None:
     if config.getoption("--run-only-snapshots"):
         selected_items: List[pytest.Item] = []
         deselected_items: List[pytest.Item] = []
@@ -91,6 +100,12 @@ def pytest_collection_modifyitems(items: pytest.Item, config: pytest.Config) ->
         config.hook.pytest_deselected(items=deselected_items)
         items[:] = selected_items
 
+    skip_lockfileless = pytest.mark.skip(reason="need --run-lockfileless to run")
+    if not config.getoption("--run-lockfileless"):
+        for item in items:
+            if "requires_lockfileless_deps" in item.keywords:
+                item.add_marker(skip_lockfileless)
+
 
 ##############################################################################
 # Helper functions/classes
diff --git a/cli/tests/default/e2e-other/parsers/test_swiftpm.py b/cli/tests/default/e2e-other/parsers/test_swiftpm.py
index 8a6bcfaabf12..f4b759cb6e2c 100644
--- a/cli/tests/default/e2e-other/parsers/test_swiftpm.py
+++ b/cli/tests/default/e2e-other/parsers/test_swiftpm.py
@@ -367,7 +367,7 @@ def test_swift_lockfile_v2_parser():
     """
     ).as_dict()
 
-    found_deps = swiftpm.parse_swiftpm_v2(
+    found_deps = swiftpm.parse_swiftpm_v2_v3(
         lockfile_path, lockfile_v2, {""}, manifest_path=None
     )
     expected_deps = [
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_dryrun/None/results.txt b/cli/tests/default/e2e-other/snapshots/test_ci/test_dryrun/None/results.txt
index befe7339ff79..5a95f3892d66 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_dryrun/None/results.txt
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_dryrun/None/results.txt
@@ -764,6 +764,44 @@ Would have sent complete blob: {
         "findings_by_product": {
             "code": 16,
             "supply-chain": 1
+        },
+        "supply_chain_stats": {
+            "subprojects_stats": [
+                {
+                    "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+                    "dependency_sources": [
+                        {
+                            "kind": [
+                                "Lockfile",
+                                "YarnLock"
+                            ],
+                            "path": "yarn.lock"
+                        }
+                    ],
+                    "resolved_stats": {
+                        "resolution_method": "LockfileParsing",
+                        "dependency_count": 1,
+                        "ecosystem": "npm"
+                    }
+                },
+                {
+                    "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+                    "dependency_sources": [
+                        {
+                            "kind": [
+                                "Lockfile",
+                                "PoetryLock"
+                            ],
+                            "path": "poetry.lock"
+                        }
+                    ],
+                    "resolved_stats": {
+                        "resolution_method": "LockfileParsing",
+                        "dependency_count": 3,
+                        "ecosystem": "pypi"
+                    }
+                }
+            ]
         }
     },
     "dependency_parser_errors": []
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-azure-pipelines/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-bitbucket/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-buildkite/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-circleci/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-enterprise/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-enterprise/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-enterprise/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-enterprise/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr-semgrepconfig/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr-semgrepconfig/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr-semgrepconfig/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr-semgrepconfig/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-pr/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-special-env-vars/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-special-env-vars/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-special-env-vars/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-special-env-vars/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-with-app-url/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-with-app-url/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-with-app-url/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push-with-app-url/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-github-push/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-push/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-push/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-push/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-push/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-special-env-vars/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-special-env-vars/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-special-env-vars/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab-special-env-vars/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-gitlab/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-missing-vars/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-missing-vars/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-missing-vars/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-missing-vars/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-jenkins/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-local/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-local/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-local/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-local/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-self-hosted/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-self-hosted/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-self-hosted/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-self-hosted/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-travis/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-unparsable_url/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-unparsable_url/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-unparsable_url/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/autofix-unparsable_url/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-azure-pipelines/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-bitbucket/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-buildkite/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-circleci/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-enterprise/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-enterprise/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-enterprise/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-enterprise/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr-semgrepconfig/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr-semgrepconfig/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr-semgrepconfig/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr-semgrepconfig/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-pr/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-special-env-vars/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-special-env-vars/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-special-env-vars/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-special-env-vars/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-with-app-url/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-with-app-url/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-with-app-url/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push-with-app-url/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-github-push/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-push/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-push/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-push/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-push/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-special-env-vars/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-special-env-vars/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-special-env-vars/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab-special-env-vars/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab/complete.json
index ada1bffd13c0..8a0071c64e73 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-gitlab/complete.json
@@ -18,6 +18,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 0
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-missing-vars/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-missing-vars/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-missing-vars/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-missing-vars/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-jenkins/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-local/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-local/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-local/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-local/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-self-hosted/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-self-hosted/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-self-hosted/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-self-hosted/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis-overwrite-autodetected-variables/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis-overwrite-autodetected-variables/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis-overwrite-autodetected-variables/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis-overwrite-autodetected-variables/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-travis/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-unparsable_url/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-unparsable_url/complete.json
index 0ff50013fce8..8a476d0e7221 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-unparsable_url/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_full_run/noautofix-unparsable_url/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_lockfile_parse_failure_reporting/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_lockfile_parse_failure_reporting/complete.json
index d06fe4fc3ffe..0243112e5a48 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_lockfile_parse_failure_reporting/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_lockfile_parse_failure_reporting/complete.json
@@ -20,6 +20,61 @@
     "findings_by_product": {
       "code": 16,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "a86c67a0a29ed0e95909b9b7c420140f302d17399ee6dcce4e1a51a14d27fd51",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PipfileLock"
+              ],
+              "path": "Pipfile.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 0,
+            "ecosystem": "pypi"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [
diff --git a/cli/tests/default/e2e-other/snapshots/test_ci/test_query_dependency/True-config/complete.json b/cli/tests/default/e2e-other/snapshots/test_ci/test_query_dependency/True-config/complete.json
index 37b3530b4a2f..153b2e9d6f40 100644
--- a/cli/tests/default/e2e-other/snapshots/test_ci/test_query_dependency/True-config/complete.json
+++ b/cli/tests/default/e2e-other/snapshots/test_ci/test_query_dependency/True-config/complete.json
@@ -20,6 +20,44 @@
     "findings_by_product": {
       "code": 9,
       "supply-chain": 1
+    },
+    "supply_chain_stats": {
+      "subprojects_stats": [
+        {
+          "subproject_id": "51e4f558fae534656963876761c95b83b6ef5da5103c4adef6768219ed76c2de",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "YarnLock"
+              ],
+              "path": "yarn.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 1,
+            "ecosystem": "npm"
+          }
+        },
+        {
+          "subproject_id": "f53a023eedfa3fbf2925ec7dc76eecdc954ea94b7e47065393dbad519613dc89",
+          "dependency_sources": [
+            {
+              "kind": [
+                "Lockfile",
+                "PoetryLock"
+              ],
+              "path": "poetry.lock"
+            }
+          ],
+          "resolved_stats": {
+            "resolution_method": "LockfileParsing",
+            "dependency_count": 3,
+            "ecosystem": "pypi"
+          }
+        }
+      ]
     }
   },
   "dependency_parser_errors": [],
diff --git a/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc/rulesdependency_awareswift-sca.yaml-dependency_awareswiftpmv3/results.txt b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc/rulesdependency_awareswift-sca.yaml-dependency_awareswiftpmv3/results.txt
new file mode 100644
index 000000000000..32fe5b94971b
--- /dev/null
+++ b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc/rulesdependency_awareswift-sca.yaml-dependency_awareswiftpmv3/results.txt
@@ -0,0 +1,101 @@
+=== exit code
+0
+=== end of exit code
+
+=== stdout - plain
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/dependency_aware/swiftpm/v3/Package.resolved",
+      "targets/dependency_aware/swiftpm/v3/Package.swift"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.dependency_aware.swift-swiftpm-sca",
+      "end": {
+        "col": 1,
+        "line": 10,
+        "offset": 1
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "        \"version\" : \"1.0.6\"",
+        "message": "oh no",
+        "metadata": {},
+        "metavars": {},
+        "sca_info": {
+          "dependency_match": {
+            "dependency_pattern": {
+              "ecosystem": "swiftpm",
+              "package": "swift-collections",
+              "semver_range": "< 2.0.6"
+            },
+            "found_dependency": {
+              "allowed_hashes": {},
+              "ecosystem": "swiftpm",
+              "git_ref": "d029d9d39c87bed85b1c50adee7c41795261a192",
+              "line_number": 10,
+              "lockfile_path": "targets/dependency_aware/swiftpm/v3/Package.resolved",
+              "manifest_path": "targets/dependency_aware/swiftpm/v3/Package.swift",
+              "package": "swift-collections",
+              "resolved_url": "https://github.com/apple/swift-collections.git",
+              "transitivity": "unknown",
+              "version": "1.0.6"
+            },
+            "lockfile": "targets/dependency_aware/swiftpm/v3/Package.resolved"
+          },
+          "reachability_rule": true,
+          "reachable": false,
+          "sca_finding_schema": 20220913
+        },
+        "severity": "WARNING"
+      },
+      "path": "targets/dependency_aware/swiftpm/v3/Package.resolved",
+      "start": {
+        "col": 1,
+        "line": 10,
+        "offset": 1
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
+=== end of stdout - plain
+
+=== stderr - plain
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 2 files tracked by git with 0 Code rules, 1 Supply Chain rule:
+
+
+  CODE RULES
+  Nothing to scan.
+
+  SUPPLY CHAIN RULES
+  Scanning 1 file.
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+
+Ran 1 rule on 2 files: 1 finding.
+
+=== end of stderr - plain
+
+=== stdout - color
+<same as above: stdout - plain>
+=== end of stdout - color
+
+=== stderr - color
+<same as above: stderr - plain>
+=== end of stderr - color
\ No newline at end of file
diff --git a/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle-no-lockfile-missing-gradlew/results.txt b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle-no-lockfile-missing-gradlew/results.txt
new file mode 100644
index 000000000000..41a9637b1512
--- /dev/null
+++ b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle-no-lockfile-missing-gradlew/results.txt
@@ -0,0 +1,59 @@
+=== command
+SEMGREP_USER_AGENT_APPEND="pytest" SEMGREP_SETTINGS_FILE="<MASKED>" SEMGREP_VERSION_CACHE_PATH="<MASKED>" SEMGREP_ENABLE_VERSION_CHECK="0" SEMGREP_SEND_METRICS="off" semgrep --allow-dynamic-dependency-resolution --strict --config rules/dependency_aware/java-gradle-sca.yaml --json targets/dependency_aware/gradle-no-lockfile-missing-gradlew
+=== end of command
+
+=== exit code
+0
+=== end of exit code
+
+=== stdout - plain
+{
+  "errors": [
+    {
+      "code": 0,
+      "level": "warn",
+      "message": "Failed to resolve dependencies for targets/dependency_aware/gradle-no-lockfile-missing-gradlew/build.gradle. Missing Requirement (Could not file gradlew in project directory or in any parent)",
+      "type": [
+        "DependencyResolutionError",
+        [
+          "MissingRequirement",
+          "Could not file gradlew in project directory or in any parent"
+        ]
+      ]
+    }
+  ],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": []
+  },
+  "results": [],
+  "skipped_rules": [],
+  "version": "0.42"
+}
+=== end of stdout - plain
+
+=== stderr - plain
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 2 files tracked by git with 0 Code rules:
+  Nothing to scan.
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+
+Ran 1 rule on 0 files: 0 findings.
+
+=== end of stderr - plain
+
+=== stdout - color
+<same as above: stdout - plain>
+=== end of stdout - color
+
+=== stderr - color
+<same as above: stderr - plain>
+=== end of stderr - color
\ No newline at end of file
diff --git a/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle-no-lockfile/results.txt b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle-no-lockfile/results.txt
new file mode 100644
index 000000000000..bf8d48c03d81
--- /dev/null
+++ b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle-no-lockfile/results.txt
@@ -0,0 +1,104 @@
+=== command
+SEMGREP_USER_AGENT_APPEND="pytest" SEMGREP_SETTINGS_FILE="<MASKED>" SEMGREP_VERSION_CACHE_PATH="<MASKED>" SEMGREP_ENABLE_VERSION_CHECK="0" SEMGREP_SEND_METRICS="off" semgrep --allow-dynamic-dependency-resolution --strict --config rules/dependency_aware/java-gradle-sca.yaml --json targets/dependency_aware/gradle-no-lockfile
+=== end of command
+
+=== exit code
+0
+=== end of exit code
+
+=== stdout - plain
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/dependency_aware/gradle-no-lockfile/build.gradle",
+      "targets/dependency_aware/gradle-no-lockfile/sca-gradle.java"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.dependency_aware.java-gradle-sca",
+      "end": {
+        "col": 21,
+        "line": 3,
+        "offset": 54
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "        return bad();",
+        "message": "oh no",
+        "metadata": {},
+        "metavars": {},
+        "sca_info": {
+          "dependency_match": {
+            "dependency_pattern": {
+              "ecosystem": "maven",
+              "package": "org.webjars.npm:swagger-ui-dist",
+              "semver_range": "<= 3.35.2"
+            },
+            "found_dependency": {
+              "allowed_hashes": {},
+              "children": [],
+              "ecosystem": "maven",
+              "lockfile_path": "targets/dependency_aware/gradle-no-lockfile/build.gradle",
+              "manifest_path": "targets/dependency_aware/gradle-no-lockfile/build.gradle",
+              "package": "org.webjars.npm:swagger-ui-dist",
+              "transitivity": "direct",
+              "version": "3.35.1"
+            },
+            "lockfile": "targets/dependency_aware/gradle-no-lockfile/build.gradle"
+          },
+          "reachability_rule": true,
+          "reachable": true,
+          "sca_finding_schema": 20220913
+        },
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/dependency_aware/gradle-no-lockfile/sca-gradle.java",
+      "start": {
+        "col": 16,
+        "line": 3,
+        "offset": 49
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
+=== end of stdout - plain
+
+=== stderr - plain
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 25 files tracked by git with 0 Code rules, 1 Supply Chain rule:
+
+
+  CODE RULES
+  Nothing to scan.
+
+  SUPPLY CHAIN RULES
+  Scanning 1 file.
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+
+Ran 1 rule on 2 files: 1 finding.
+
+=== end of stderr - plain
+
+=== stdout - color
+<same as above: stdout - plain>
+=== end of stdout - color
+
+=== stderr - color
+<same as above: stderr - plain>
+=== end of stderr - color
\ No newline at end of file
diff --git a/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle/results.txt b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle/results.txt
new file mode 100644
index 000000000000..d106ef885c11
--- /dev/null
+++ b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awarejava-gradle-sca.yaml-dependency_awaregradle/results.txt
@@ -0,0 +1,104 @@
+=== command
+SEMGREP_USER_AGENT_APPEND="pytest" SEMGREP_SETTINGS_FILE="<MASKED>" SEMGREP_VERSION_CACHE_PATH="<MASKED>" SEMGREP_ENABLE_VERSION_CHECK="0" SEMGREP_SEND_METRICS="off" semgrep --allow-dynamic-dependency-resolution --strict --config rules/dependency_aware/java-gradle-sca.yaml --json targets/dependency_aware/gradle
+=== end of command
+
+=== exit code
+0
+=== end of exit code
+
+=== stdout - plain
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/dependency_aware/gradle/gradle.lockfile",
+      "targets/dependency_aware/gradle/sca-gradle.java"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.dependency_aware.java-gradle-sca",
+      "end": {
+        "col": 21,
+        "line": 3,
+        "offset": 54
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "        return bad();",
+        "message": "oh no",
+        "metadata": {},
+        "metavars": {},
+        "sca_info": {
+          "dependency_match": {
+            "dependency_pattern": {
+              "ecosystem": "maven",
+              "package": "org.webjars.npm:swagger-ui-dist",
+              "semver_range": "<= 3.35.2"
+            },
+            "found_dependency": {
+              "allowed_hashes": {},
+              "ecosystem": "maven",
+              "line_number": 317,
+              "lockfile_path": "targets/dependency_aware/gradle/gradle.lockfile",
+              "manifest_path": "targets/dependency_aware/gradle/build.gradle",
+              "package": "org.webjars.npm:swagger-ui-dist",
+              "transitivity": "transitive",
+              "version": "3.35.2"
+            },
+            "lockfile": "targets/dependency_aware/gradle/gradle.lockfile"
+          },
+          "reachability_rule": true,
+          "reachable": true,
+          "sca_finding_schema": 20220913
+        },
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/dependency_aware/gradle/sca-gradle.java",
+      "start": {
+        "col": 16,
+        "line": 3,
+        "offset": 49
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
+=== end of stdout - plain
+
+=== stderr - plain
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 3 files tracked by git with 0 Code rules, 1 Supply Chain rule:
+
+
+  CODE RULES
+  Nothing to scan.
+
+  SUPPLY CHAIN RULES
+  Scanning 1 file.
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+
+Ran 1 rule on 2 files: 1 finding.
+
+=== end of stderr - plain
+
+=== stdout - color
+<same as above: stdout - plain>
+=== end of stdout - color
+
+=== stderr - color
+<same as above: stderr - plain>
+=== end of stderr - color
\ No newline at end of file
diff --git a/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awaremaven-log4j.yaml-dependency_awaremaven-no-lockfile/results.txt b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awaremaven-log4j.yaml-dependency_awaremaven-no-lockfile/results.txt
new file mode 100644
index 000000000000..0b821680964c
--- /dev/null
+++ b/cli/tests/default/e2e-other/snapshots/test_ssc/test_ssc__lockfileless/rulesdependency_awaremaven-log4j.yaml-dependency_awaremaven-no-lockfile/results.txt
@@ -0,0 +1,107 @@
+=== command
+SEMGREP_USER_AGENT_APPEND="pytest" SEMGREP_SETTINGS_FILE="<MASKED>" SEMGREP_VERSION_CACHE_PATH="<MASKED>" SEMGREP_ENABLE_VERSION_CHECK="0" SEMGREP_SEND_METRICS="off" semgrep --allow-dynamic-dependency-resolution --strict --config rules/dependency_aware/maven-log4j.yaml --json targets/dependency_aware/maven-no-lockfile
+=== end of command
+
+=== exit code
+0
+=== end of exit code
+
+=== stdout - plain
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/dependency_aware/maven-no-lockfile/pom.xml"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.dependency_aware.maven-log4j",
+      "end": {
+        "col": 1,
+        "line": 1,
+        "offset": 1
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
+        "message": "got em",
+        "metadata": {},
+        "metavars": {},
+        "sca_info": {
+          "dependency_match": {
+            "dependency_pattern": {
+              "ecosystem": "maven",
+              "package": "org.apache.logging.log4j:log4j-core",
+              "semver_range": "<= 2.15.0"
+            },
+            "found_dependency": {
+              "allowed_hashes": {},
+              "children": [
+                {
+                  "package": "org.apache.logging.log4j:log4j-api",
+                  "version": "2.15.0"
+                }
+              ],
+              "ecosystem": "maven",
+              "lockfile_path": "targets/dependency_aware/maven-no-lockfile/pom.xml",
+              "manifest_path": "targets/dependency_aware/maven-no-lockfile/pom.xml",
+              "package": "org.apache.logging.log4j:log4j-core",
+              "transitivity": "direct",
+              "version": "2.15.0"
+            },
+            "lockfile": "targets/dependency_aware/maven-no-lockfile/pom.xml"
+          },
+          "reachability_rule": false,
+          "reachable": false,
+          "sca_finding_schema": 20220913
+        },
+        "severity": "WARNING"
+      },
+      "path": "targets/dependency_aware/maven-no-lockfile/pom.xml",
+      "start": {
+        "col": 1,
+        "line": 1,
+        "offset": 1
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
+=== end of stdout - plain
+
+=== stderr - plain
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 1 file tracked by git with 0 Code rules, 1 Supply Chain rule:
+
+
+  CODE RULES
+  Nothing to scan.
+
+  SUPPLY CHAIN RULES
+  Nothing to scan.
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+
+Ran 1 rule on 1 file: 1 finding.
+
+=== end of stderr - plain
+
+=== stdout - color
+<same as above: stdout - plain>
+=== end of stdout - color
+
+=== stderr - color
+<same as above: stderr - plain>
+=== end of stderr - color
\ No newline at end of file
diff --git a/cli/tests/default/e2e-other/test_ssc.py b/cli/tests/default/e2e-other/test_ssc.py
index 5451137e09e9..8ee049d551d2 100644
--- a/cli/tests/default/e2e-other/test_ssc.py
+++ b/cli/tests/default/e2e-other/test_ssc.py
@@ -1,6 +1,7 @@
 import json
 import logging
 from pathlib import Path
+from typing import Any
 from typing import Dict
 
 import pytest
@@ -242,6 +243,10 @@
             "rules/dependency_aware/swift-sca.yaml",
             "dependency_aware/swiftpm/v2",
         ),
+        (
+            "rules/dependency_aware/swift-sca.yaml",
+            "dependency_aware/swiftpm/v3",
+        ),
         (
             "rules/dependency_aware/swift-sca.yaml",
             "dependency_aware/swiftpm_missing_version",
@@ -341,7 +346,7 @@ def test_ssc(run_semgrep_on_copied_files: RunSemgrep, snapshot, rule, target):
 )
 @pytest.mark.osemfail
 def test_ssc__requirements_lockfiles(
-    run_semgrep_on_copied_files: RunSemgrep, snapshot, rule, target
+    run_semgrep_on_copied_files: RunSemgrep, snapshot, rule: str, target: str
 ):
     """
     Separated out from test_ssc to avoid polluting with extra requirements lockfile tests
@@ -354,6 +359,45 @@ def test_ssc__requirements_lockfiles(
     )
 
 
+@pytest.mark.parametrize(
+    "rule,target",
+    [
+        (
+            "rules/dependency_aware/java-gradle-sca.yaml",
+            "dependency_aware/gradle-no-lockfile",
+        ),
+        (
+            "rules/dependency_aware/java-gradle-sca.yaml",
+            "dependency_aware/gradle-no-lockfile-missing-gradlew",
+        ),
+        (
+            "rules/dependency_aware/maven-log4j.yaml",
+            "dependency_aware/maven-no-lockfile",
+        ),
+        # also include some where lockfiles are present to check that we don't break those
+        (
+            "rules/dependency_aware/java-gradle-sca.yaml",
+            "dependency_aware/gradle",
+        ),
+    ],
+)
+@pytest.mark.requires_lockfileless_deps
+def test_ssc__lockfileless(
+    run_semgrep_on_copied_files: RunSemgrep, snapshot: Any, rule: str, target: str
+):
+    """
+    Run end-to-end SSC tests with lockfileless resolution enabled.
+    """
+    result = run_semgrep_on_copied_files(
+        rule, target_name=target, options=["--allow-dynamic-dependency-resolution"]
+    )
+
+    snapshot.assert_match(
+        result.as_snapshot(),
+        "results.txt",
+    )
+
+
 @pytest.mark.parametrize(
     "rule,target",
     [
diff --git a/cli/tests/default/e2e/rules/dependency_aware/maven-log4j.yaml b/cli/tests/default/e2e/rules/dependency_aware/maven-log4j.yaml
new file mode 100644
index 000000000000..719fbb3734ae
--- /dev/null
+++ b/cli/tests/default/e2e/rules/dependency_aware/maven-log4j.yaml
@@ -0,0 +1,9 @@
+rules:
+  - id: maven-log4j
+    r2c-internal-project-depends-on:
+      namespace: maven
+      package: org.apache.logging.log4j:log4j-core
+      version: "<= 2.15.0"
+    message: got em
+    languages: [java]
+    severity: WARNING
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target0/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target0/results.sarif
new file mode 100644
index 000000000000..fbb2db1f3ff4
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target0/results.sarif
@@ -0,0 +1,138 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "62b4a09c4569768898c43c09fa0a5b95b7e93257ef3a0911a5c379b6265b4d49fa4aecd5782461632e9aef4779af02d7cad4405b9a5318a0e5ffe9a5bd8daeae_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/basic/stupid.py",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 26,
+                  "endLine": 3,
+                  "snippet": {
+                    "text": "    return a + b == a + b"
+                  },
+                  "startColumn": 12,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "useless comparison operation `a + b == a + b` or `a + b != a + b`"
+          },
+          "properties": {},
+          "ruleId": "rules.eqeq-is-bad"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "possibly useless comparison but in eq function"
+              },
+              "help": {
+                "markdown": "possibly useless comparison but in eq function",
+                "text": "possibly useless comparison but in eq function"
+              },
+              "id": "rules.assert-eqeq-is-ok",
+              "name": "rules.assert-eqeq-is-ok",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.assert-eqeq-is-ok"
+              }
+            },
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "useless comparison operation `$X == $X` or `$X != $X`"
+              },
+              "help": {
+                "markdown": "useless comparison operation `$X == $X` or `$X != $X`\n\n<b>References:</b>\n - [Semgrep Rule](https://semgrep.dev/r/eqeq-bad)\n",
+                "text": "useless comparison operation `$X == $X` or `$X != $X`"
+              },
+              "helpUri": "https://semgrep.dev/r/eqeq-bad",
+              "id": "rules.eqeq-is-bad",
+              "name": "rules.eqeq-is-bad",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.eqeq-is-bad"
+              }
+            },
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "useless comparison"
+              },
+              "help": {
+                "markdown": "useless comparison",
+                "text": "useless comparison"
+              },
+              "id": "rules.javascript-basic-eqeq-bad",
+              "name": "rules.javascript-basic-eqeq-bad",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.javascript-basic-eqeq-bad"
+              }
+            },
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "this function is only available on Python 3.7+"
+              },
+              "help": {
+                "markdown": "this function is only available on Python 3.7+",
+                "text": "this function is only available on Python 3.7+"
+              },
+              "id": "rules.python37-compatability-os-module",
+              "name": "rules.python37-compatability-os-module",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.python37-compatability-os-module"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target1/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target1/results.sarif
new file mode 100644
index 000000000000..508565cea1e8
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target1/results.sarif
@@ -0,0 +1,107 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "c14751d4f27243ab3fc24c8e9993358c58673b1f8cb907fc90f66a33826c9183a0386cc160cf4457cf7e175c9efb083d250a3b52fe3154a003fc9b9955802118_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/basic/stupid.py",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 17,
+                  "endLine": 3,
+                  "snippet": {
+                    "text": "    return a + b == a + b"
+                  },
+                  "startColumn": 12,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "Fake message."
+          },
+          "properties": {},
+          "ruleId": "rules.rule-with-cwe-tag"
+        },
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "c14751d4f27243ab3fc24c8e9993358c58673b1f8cb907fc90f66a33826c9183a0386cc160cf4457cf7e175c9efb083d250a3b52fe3154a003fc9b9955802118_1"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/basic/stupid.py",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 26,
+                  "endLine": 3,
+                  "snippet": {
+                    "text": "    return a + b == a + b"
+                  },
+                  "startColumn": 21,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "Fake message."
+          },
+          "properties": {},
+          "ruleId": "rules.rule-with-cwe-tag"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "Fake message."
+              },
+              "help": {
+                "markdown": "Fake message.",
+                "text": "Fake message."
+              },
+              "id": "rules.rule-with-cwe-tag",
+              "name": "rules.rule-with-cwe-tag",
+              "properties": {
+                "precision": "very-high",
+                "tags": [
+                  "CWE-88888888: Another fake CWE",
+                  "CWE-99999999: Fake CWE",
+                  "security"
+                ]
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.rule-with-cwe-tag"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target2/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target2/results.sarif
new file mode 100644
index 000000000000..0ea0c3405d39
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target2/results.sarif
@@ -0,0 +1,44 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "rules with metavariable type"
+              },
+              "help": {
+                "markdown": "rules with metavariable type",
+                "text": "rules with metavariable type"
+              },
+              "id": "rules.metavar-type",
+              "name": "rules.metavar-type",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.metavar-type"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target3/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target3/results.sarif
new file mode 100644
index 000000000000..9cfc7806085c
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/False-rule_and_target3/results.sarif
@@ -0,0 +1,74 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "c103a760f6ce7176c2d5127a8c5afa47e83d41bef95586322d99febf0f148c2808bc6b168b31948e8fd10576f7f4edd69fb6d341c25ed9c259cde4e9164d7b96_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/taint/taint_trace.cpp",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 14,
+                  "endLine": 17,
+                  "snippet": {
+                    "text": "    SINK(res2);                               // (5) sink res2"
+                  },
+                  "startColumn": 10,
+                  "startLine": 17
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "found an error"
+          },
+          "properties": {},
+          "ruleId": "rules.taint-trace"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "warning"
+              },
+              "fullDescription": {
+                "text": "found an error"
+              },
+              "help": {
+                "markdown": "found an error",
+                "text": "found an error"
+              },
+              "id": "rules.taint-trace",
+              "name": "rules.taint-trace",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.taint-trace"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target0/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target0/results.sarif
new file mode 100644
index 000000000000..fbb2db1f3ff4
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target0/results.sarif
@@ -0,0 +1,138 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "62b4a09c4569768898c43c09fa0a5b95b7e93257ef3a0911a5c379b6265b4d49fa4aecd5782461632e9aef4779af02d7cad4405b9a5318a0e5ffe9a5bd8daeae_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/basic/stupid.py",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 26,
+                  "endLine": 3,
+                  "snippet": {
+                    "text": "    return a + b == a + b"
+                  },
+                  "startColumn": 12,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "useless comparison operation `a + b == a + b` or `a + b != a + b`"
+          },
+          "properties": {},
+          "ruleId": "rules.eqeq-is-bad"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "possibly useless comparison but in eq function"
+              },
+              "help": {
+                "markdown": "possibly useless comparison but in eq function",
+                "text": "possibly useless comparison but in eq function"
+              },
+              "id": "rules.assert-eqeq-is-ok",
+              "name": "rules.assert-eqeq-is-ok",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.assert-eqeq-is-ok"
+              }
+            },
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "useless comparison operation `$X == $X` or `$X != $X`"
+              },
+              "help": {
+                "markdown": "useless comparison operation `$X == $X` or `$X != $X`\n\n<b>References:</b>\n - [Semgrep Rule](https://semgrep.dev/r/eqeq-bad)\n",
+                "text": "useless comparison operation `$X == $X` or `$X != $X`"
+              },
+              "helpUri": "https://semgrep.dev/r/eqeq-bad",
+              "id": "rules.eqeq-is-bad",
+              "name": "rules.eqeq-is-bad",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.eqeq-is-bad"
+              }
+            },
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "useless comparison"
+              },
+              "help": {
+                "markdown": "useless comparison",
+                "text": "useless comparison"
+              },
+              "id": "rules.javascript-basic-eqeq-bad",
+              "name": "rules.javascript-basic-eqeq-bad",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.javascript-basic-eqeq-bad"
+              }
+            },
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "this function is only available on Python 3.7+"
+              },
+              "help": {
+                "markdown": "this function is only available on Python 3.7+",
+                "text": "this function is only available on Python 3.7+"
+              },
+              "id": "rules.python37-compatability-os-module",
+              "name": "rules.python37-compatability-os-module",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.python37-compatability-os-module"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target1/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target1/results.sarif
new file mode 100644
index 000000000000..508565cea1e8
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target1/results.sarif
@@ -0,0 +1,107 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "c14751d4f27243ab3fc24c8e9993358c58673b1f8cb907fc90f66a33826c9183a0386cc160cf4457cf7e175c9efb083d250a3b52fe3154a003fc9b9955802118_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/basic/stupid.py",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 17,
+                  "endLine": 3,
+                  "snippet": {
+                    "text": "    return a + b == a + b"
+                  },
+                  "startColumn": 12,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "Fake message."
+          },
+          "properties": {},
+          "ruleId": "rules.rule-with-cwe-tag"
+        },
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "c14751d4f27243ab3fc24c8e9993358c58673b1f8cb907fc90f66a33826c9183a0386cc160cf4457cf7e175c9efb083d250a3b52fe3154a003fc9b9955802118_1"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/basic/stupid.py",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 26,
+                  "endLine": 3,
+                  "snippet": {
+                    "text": "    return a + b == a + b"
+                  },
+                  "startColumn": 21,
+                  "startLine": 3
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "Fake message."
+          },
+          "properties": {},
+          "ruleId": "rules.rule-with-cwe-tag"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "Fake message."
+              },
+              "help": {
+                "markdown": "Fake message.",
+                "text": "Fake message."
+              },
+              "id": "rules.rule-with-cwe-tag",
+              "name": "rules.rule-with-cwe-tag",
+              "properties": {
+                "precision": "very-high",
+                "tags": [
+                  "CWE-88888888: Another fake CWE",
+                  "CWE-99999999: Fake CWE",
+                  "security"
+                ]
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.rule-with-cwe-tag"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target2/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target2/results.sarif
new file mode 100644
index 000000000000..0ea0c3405d39
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target2/results.sarif
@@ -0,0 +1,44 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "error"
+              },
+              "fullDescription": {
+                "text": "rules with metavariable type"
+              },
+              "help": {
+                "markdown": "rules with metavariable type",
+                "text": "rules with metavariable type"
+              },
+              "id": "rules.metavar-type",
+              "name": "rules.metavar-type",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.metavar-type"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target3/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target3/results.sarif
new file mode 100644
index 000000000000..b4f165cb6ca6
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output/True-rule_and_target3/results.sarif
@@ -0,0 +1,212 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "codeFlows": [
+            {
+              "message": {
+                "text": "Untrusted dataflow from targets/taint/taint_trace.cpp:10 to targets/taint/taint_trace.cpp:17"
+              },
+              "threadFlows": [
+                {
+                  "locations": [
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Source: 'SOURCE()' @ 'targets/taint/taint_trace.cpp:10'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 22,
+                            "endLine": 10,
+                            "message": {
+                              "text": "Source: 'SOURCE()' @ 'targets/taint/taint_trace.cpp:10'"
+                            },
+                            "snippet": {
+                              "text": "SOURCE()"
+                            },
+                            "startColumn": 14,
+                            "startLine": 10
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Propagator : 'res1' @ 'targets/taint/taint_trace.cpp:10'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 11,
+                            "endLine": 10,
+                            "message": {
+                              "text": "Propagator : 'res1' @ 'targets/taint/taint_trace.cpp:10'"
+                            },
+                            "snippet": {
+                              "text": "res1"
+                            },
+                            "startColumn": 7,
+                            "startLine": 10
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Propagator : 'curBase' @ 'targets/taint/taint_trace.cpp:15'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 12,
+                            "endLine": 15,
+                            "message": {
+                              "text": "Propagator : 'curBase' @ 'targets/taint/taint_trace.cpp:15'"
+                            },
+                            "snippet": {
+                              "text": "curBase"
+                            },
+                            "startColumn": 5,
+                            "startLine": 15
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Propagator : 'res2' @ 'targets/taint/taint_trace.cpp:12'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 11,
+                            "endLine": 12,
+                            "message": {
+                              "text": "Propagator : 'res2' @ 'targets/taint/taint_trace.cpp:12'"
+                            },
+                            "snippet": {
+                              "text": "res2"
+                            },
+                            "startColumn": 7,
+                            "startLine": 12
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Sink: 'SINK(res2);                               // (5) sink res2' @ 'targets/taint/taint_trace.cpp:17'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 14,
+                            "endLine": 17,
+                            "message": {
+                              "text": "Sink: 'SINK(res2);                               // (5) sink res2' @ 'targets/taint/taint_trace.cpp:17'"
+                            },
+                            "snippet": {
+                              "text": "    SINK(res2);                               // (5) sink res2"
+                            },
+                            "startColumn": 10,
+                            "startLine": 17
+                          }
+                        }
+                      },
+                      "nestingLevel": 1
+                    }
+                  ]
+                }
+              ]
+            }
+          ],
+          "fingerprints": {
+            "matchBasedId/v1": "c103a760f6ce7176c2d5127a8c5afa47e83d41bef95586322d99febf0f148c2808bc6b168b31948e8fd10576f7f4edd69fb6d341c25ed9c259cde4e9164d7b96_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/taint/taint_trace.cpp",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 14,
+                  "endLine": 17,
+                  "snippet": {
+                    "text": "    SINK(res2);                               // (5) sink res2"
+                  },
+                  "startColumn": 10,
+                  "startLine": 17
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "found an error"
+          },
+          "properties": {},
+          "ruleId": "rules.taint-trace"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "warning"
+              },
+              "fullDescription": {
+                "text": "found an error"
+              },
+              "help": {
+                "markdown": "found an error",
+                "text": "found an error"
+              },
+              "id": "rules.taint-trace",
+              "name": "rules.taint-trace",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.taint-trace"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output_osemfail/False-rule_and_target0/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output_osemfail/False-rule_and_target0/results.sarif
new file mode 100644
index 000000000000..9cfc7806085c
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output_osemfail/False-rule_and_target0/results.sarif
@@ -0,0 +1,74 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "fingerprints": {
+            "matchBasedId/v1": "c103a760f6ce7176c2d5127a8c5afa47e83d41bef95586322d99febf0f148c2808bc6b168b31948e8fd10576f7f4edd69fb6d341c25ed9c259cde4e9164d7b96_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/taint/taint_trace.cpp",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 14,
+                  "endLine": 17,
+                  "snippet": {
+                    "text": "    SINK(res2);                               // (5) sink res2"
+                  },
+                  "startColumn": 10,
+                  "startLine": 17
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "found an error"
+          },
+          "properties": {},
+          "ruleId": "rules.taint-trace"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "warning"
+              },
+              "fullDescription": {
+                "text": "found an error"
+              },
+              "help": {
+                "markdown": "found an error",
+                "text": "found an error"
+              },
+              "id": "rules.taint-trace",
+              "name": "rules.taint-trace",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.taint-trace"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output_osemfail/True-rule_and_target0/results.sarif b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output_osemfail/True-rule_and_target0/results.sarif
new file mode 100644
index 000000000000..b4f165cb6ca6
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_output_sarif/test_sarif_output_osemfail/True-rule_and_target0/results.sarif
@@ -0,0 +1,212 @@
+{
+  "$schema": "https://docs.oasis-open.org/sarif/sarif/v2.1.0/os/schemas/sarif-schema-2.1.0.json",
+  "runs": [
+    {
+      "invocations": [
+        {
+          "executionSuccessful": true,
+          "toolExecutionNotifications": []
+        }
+      ],
+      "results": [
+        {
+          "codeFlows": [
+            {
+              "message": {
+                "text": "Untrusted dataflow from targets/taint/taint_trace.cpp:10 to targets/taint/taint_trace.cpp:17"
+              },
+              "threadFlows": [
+                {
+                  "locations": [
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Source: 'SOURCE()' @ 'targets/taint/taint_trace.cpp:10'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 22,
+                            "endLine": 10,
+                            "message": {
+                              "text": "Source: 'SOURCE()' @ 'targets/taint/taint_trace.cpp:10'"
+                            },
+                            "snippet": {
+                              "text": "SOURCE()"
+                            },
+                            "startColumn": 14,
+                            "startLine": 10
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Propagator : 'res1' @ 'targets/taint/taint_trace.cpp:10'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 11,
+                            "endLine": 10,
+                            "message": {
+                              "text": "Propagator : 'res1' @ 'targets/taint/taint_trace.cpp:10'"
+                            },
+                            "snippet": {
+                              "text": "res1"
+                            },
+                            "startColumn": 7,
+                            "startLine": 10
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Propagator : 'curBase' @ 'targets/taint/taint_trace.cpp:15'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 12,
+                            "endLine": 15,
+                            "message": {
+                              "text": "Propagator : 'curBase' @ 'targets/taint/taint_trace.cpp:15'"
+                            },
+                            "snippet": {
+                              "text": "curBase"
+                            },
+                            "startColumn": 5,
+                            "startLine": 15
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Propagator : 'res2' @ 'targets/taint/taint_trace.cpp:12'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 11,
+                            "endLine": 12,
+                            "message": {
+                              "text": "Propagator : 'res2' @ 'targets/taint/taint_trace.cpp:12'"
+                            },
+                            "snippet": {
+                              "text": "res2"
+                            },
+                            "startColumn": 7,
+                            "startLine": 12
+                          }
+                        }
+                      },
+                      "nestingLevel": 0
+                    },
+                    {
+                      "location": {
+                        "message": {
+                          "text": "Sink: 'SINK(res2);                               // (5) sink res2' @ 'targets/taint/taint_trace.cpp:17'"
+                        },
+                        "physicalLocation": {
+                          "artifactLocation": {
+                            "uri": "targets/taint/taint_trace.cpp"
+                          },
+                          "region": {
+                            "endColumn": 14,
+                            "endLine": 17,
+                            "message": {
+                              "text": "Sink: 'SINK(res2);                               // (5) sink res2' @ 'targets/taint/taint_trace.cpp:17'"
+                            },
+                            "snippet": {
+                              "text": "    SINK(res2);                               // (5) sink res2"
+                            },
+                            "startColumn": 10,
+                            "startLine": 17
+                          }
+                        }
+                      },
+                      "nestingLevel": 1
+                    }
+                  ]
+                }
+              ]
+            }
+          ],
+          "fingerprints": {
+            "matchBasedId/v1": "c103a760f6ce7176c2d5127a8c5afa47e83d41bef95586322d99febf0f148c2808bc6b168b31948e8fd10576f7f4edd69fb6d341c25ed9c259cde4e9164d7b96_0"
+          },
+          "locations": [
+            {
+              "physicalLocation": {
+                "artifactLocation": {
+                  "uri": "targets/taint/taint_trace.cpp",
+                  "uriBaseId": "%SRCROOT%"
+                },
+                "region": {
+                  "endColumn": 14,
+                  "endLine": 17,
+                  "snippet": {
+                    "text": "    SINK(res2);                               // (5) sink res2"
+                  },
+                  "startColumn": 10,
+                  "startLine": 17
+                }
+              }
+            }
+          ],
+          "message": {
+            "text": "found an error"
+          },
+          "properties": {},
+          "ruleId": "rules.taint-trace"
+        }
+      ],
+      "tool": {
+        "driver": {
+          "name": "Semgrep OSS",
+          "rules": [
+            {
+              "defaultConfiguration": {
+                "level": "warning"
+              },
+              "fullDescription": {
+                "text": "found an error"
+              },
+              "help": {
+                "markdown": "found an error",
+                "text": "found an error"
+              },
+              "id": "rules.taint-trace",
+              "name": "rules.taint-trace",
+              "properties": {
+                "precision": "very-high",
+                "tags": []
+              },
+              "shortDescription": {
+                "text": "Semgrep Finding: rules.taint-trace"
+              }
+            }
+          ],
+          "semanticVersion": "placeholder"
+        }
+      }
+    }
+  ],
+  "version": "2.1.0"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile-missing-gradlew/build.gradle b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile-missing-gradlew/build.gradle
new file mode 100644
index 000000000000..8d1ebe7a0325
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile-missing-gradlew/build.gradle
@@ -0,0 +1,54 @@
+plugins {
+  id "com.github.mxenabled.coppuccino" version "3.2.1"
+  id "com.github.mxenabled.vogue" version "1.0.2"
+  id "java"
+  id "maven-publish"
+  id "groovy"
+  id "io.freefair.lombok" version "6.4.3"
+}
+
+group "com.mx.path.example"
+version "0.0.1"
+jar.archiveBaseName = "path-examples"
+
+allprojects {
+  repositories {
+    mavenCentral()
+    mavenLocal()
+  }
+}
+
+sourceCompatibility = 1.8
+targetCompatibility = 1.8
+
+dependencies {
+  implementation platform("com.mx.path-facilities:platform:[1.0,2.0[")
+  implementation platform("com.mx.path-mdx-model:platform:[1.0,2.0[")
+  implementation "com.mx.path-core:http"
+  implementation "com.mx.path-facilities:message-broker-nats"
+  implementation "com.mx.path-mdx-model:mdx-gateways"
+
+  implementation "info.picocli:picocli:4.6.3" // for commandline features
+  implementation "commons-io:commons-io:20030203.000550" // for file reading
+  implementation "org.slf4j:slf4j-nop:[1.7.0,1.8[" // silence log messages
+  implementation "io.opentracing:opentracing-mock:0.33.0" // for request tracing
+
+  testImplementation "org.mockito:mockito-inline:[4.0,5.0["
+  testImplementation "org.spockframework:spock-core:[2.0,3.0["
+  testImplementation "org.junit.jupiter:junit-jupiter-api:[5.0,6.0["
+}
+
+sourceSets {
+  main {
+    java {
+      exclude "path/e100_spring_app/**"
+    }
+  }
+}
+
+artifacts { archives jar }
+
+wrapper {
+  gradleVersion = "7.4.2"
+  distributionType = Wrapper.DistributionType.ALL
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile-missing-gradlew/sca-gradle.java b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile-missing-gradlew/sca-gradle.java
new file mode 100644
index 000000000000..6f291ad84528
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile-missing-gradlew/sca-gradle.java
@@ -0,0 +1,5 @@
+public class main {
+    int f() {
+        return bad();
+    }
+}
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/.gitignore b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/.gitignore
new file mode 100644
index 000000000000..4a8da3bce05a
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/.gitignore
@@ -0,0 +1,1 @@
+.gradle
\ No newline at end of file
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/build.gradle b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/build.gradle
new file mode 100644
index 000000000000..30cb1d5806b6
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/build.gradle
@@ -0,0 +1,11 @@
+plugins {
+    id("application")
+}
+
+repositories {
+	mavenCentral()
+}
+
+dependencies {
+    implementation 'org.webjars.npm:swagger-ui-dist:3.35.1'
+}
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradle/wrapper/gradle-wrapper.jar b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 000000000000..a4b76b9530d6
Binary files /dev/null and b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradle/wrapper/gradle-wrapper.properties b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 000000000000..df97d72b8b91
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,7 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip
+networkTimeout=10000
+validateDistributionUrl=true
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradlew b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradlew
new file mode 100755
index 000000000000..f5feea6d6b11
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradlew
@@ -0,0 +1,252 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# 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
+#
+#      https://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.
+#
+# SPDX-License-Identifier: Apache-2.0
+#
+
+##############################################################################
+#
+#   Gradle start up script for POSIX generated by Gradle.
+#
+#   Important for running:
+#
+#   (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+#       noncompliant, but you have some other compliant shell such as ksh or
+#       bash, then to run this script, type that shell name before the whole
+#       command line, like:
+#
+#           ksh Gradle
+#
+#       Busybox and similar reduced shells will NOT work, because this script
+#       requires all of these POSIX shell features:
+#         * functions;
+#         * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+#           «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+#         * compound commands having a testable exit status, especially «case»;
+#         * various built-in commands including «command», «set», and «ulimit».
+#
+#   Important for patching:
+#
+#   (2) This script targets any POSIX shell, so it avoids extensions provided
+#       by Bash, Ksh, etc; in particular arrays are avoided.
+#
+#       The "traditional" practice of packing multiple parameters into a
+#       space-separated string is a well documented source of bugs and security
+#       problems, so this is (mostly) avoided, by progressively accumulating
+#       options in "$@", and eventually passing that to Java.
+#
+#       Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+#       and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+#       see the in-line comments for details.
+#
+#       There are tweaks for specific operating systems such as AIX, CygWin,
+#       Darwin, MinGW, and NonStop.
+#
+#   (3) This script is generated from the Groovy template
+#       https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       within the Gradle project.
+#
+#       You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+    APP_HOME=${app_path%"${app_path##*/}"}  # leaves a trailing /; empty if no leading path
+    [ -h "$app_path" ]
+do
+    ls=$( ls -ld "$app_path" )
+    link=${ls#*' -> '}
+    case $link in             #(
+      /*)   app_path=$link ;; #(
+      *)    app_path=$APP_HOME$link ;;
+    esac
+done
+
+# This is normally unused
+# shellcheck disable=SC2034
+APP_BASE_NAME=${0##*/}
+# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+    echo "$*"
+} >&2
+
+die () {
+    echo
+    echo "$*"
+    echo
+    exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in                #(
+  CYGWIN* )         cygwin=true  ;; #(
+  Darwin* )         darwin=true  ;; #(
+  MSYS* | MINGW* )  msys=true    ;; #(
+  NONSTOP* )        nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+    if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+        # IBM's JDK on AIX uses strange locations for the executables
+        JAVACMD=$JAVA_HOME/jre/sh/java
+    else
+        JAVACMD=$JAVA_HOME/bin/java
+    fi
+    if [ ! -x "$JAVACMD" ] ; then
+        die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+else
+    JAVACMD=java
+    if ! command -v java >/dev/null 2>&1
+    then
+        die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+    fi
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+    case $MAX_FD in #(
+      max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        MAX_FD=$( ulimit -H -n ) ||
+            warn "Could not query maximum file descriptor limit"
+    esac
+    case $MAX_FD in  #(
+      '' | soft) :;; #(
+      *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC2039,SC3045
+        ulimit -n "$MAX_FD" ||
+            warn "Could not set maximum file descriptor limit to $MAX_FD"
+    esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+#   * args from the command line
+#   * the main class name
+#   * -classpath
+#   * -D...appname settings
+#   * --module-path (only if needed)
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+    APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+    CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+    JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+    # Now convert the arguments - kludge to limit ourselves to /bin/sh
+    for arg do
+        if
+            case $arg in                                #(
+              -*)   false ;;                            # don't mess with options #(
+              /?*)  t=${arg#/} t=/${t%%/*}              # looks like a POSIX filepath
+                    [ -e "$t" ] ;;                      #(
+              *)    false ;;
+            esac
+        then
+            arg=$( cygpath --path --ignore --mixed "$arg" )
+        fi
+        # Roll the args list around exactly as many times as the number of
+        # args, so each arg winds up back in the position where it started, but
+        # possibly modified.
+        #
+        # NB: a `for` loop captures its iteration list before it begins, so
+        # changing the positional parameters here affects neither the number of
+        # iterations, nor the values presented in `arg`.
+        shift                   # remove old arg
+        set -- "$@" "$arg"      # push replacement arg
+    done
+fi
+
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Collect all arguments for the java command:
+#   * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
+#     and any embedded shellness will be escaped.
+#   * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
+#     treated as '${Hostname}' itself on the command line.
+
+set -- \
+        "-Dorg.gradle.appname=$APP_BASE_NAME" \
+        -classpath "$CLASSPATH" \
+        org.gradle.wrapper.GradleWrapperMain \
+        "$@"
+
+# Stop when "xargs" is not available.
+if ! command -v xargs >/dev/null 2>&1
+then
+    die "xargs is not available"
+fi
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+#   readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+#   set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+        printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+        xargs -n1 |
+        sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+        tr '\n' ' '
+    )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradlew.bat b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradlew.bat
new file mode 100644
index 000000000000..9b42019c7915
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/gradlew.bat
@@ -0,0 +1,94 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem      https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
+
+@if "%DEBUG%"=="" @echo off
+@rem ##########################################################################
+@rem
+@rem  Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if %ERRORLEVEL% equ 0 goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if %ERRORLEVEL% equ 0 goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+set EXIT_CODE=%ERRORLEVEL%
+if %EXIT_CODE% equ 0 set EXIT_CODE=1
+if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
+exit /b %EXIT_CODE%
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/sca-gradle.java b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/sca-gradle.java
new file mode 100644
index 000000000000..6f291ad84528
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/sca-gradle.java
@@ -0,0 +1,5 @@
+public class main {
+    int f() {
+        return bad();
+    }
+}
diff --git a/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/semgrep-dependencies-init.gradle b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/semgrep-dependencies-init.gradle
new file mode 100644
index 000000000000..058a9a33005a
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/gradle-no-lockfile/semgrep-dependencies-init.gradle
@@ -0,0 +1,9 @@
+initscript {
+  repositories {
+      gradlePluginPortal()
+  }
+  dependencies {
+      classpath "org.gradle:github-dependency-graph-gradle-plugin:+"
+  }
+}
+apply plugin: org.gradle.dependencygraph.simple.SimpleDependencyGraphPlugin
diff --git a/cli/tests/default/e2e/targets/dependency_aware/maven-no-lockfile/pom.xml b/cli/tests/default/e2e/targets/dependency_aware/maven-no-lockfile/pom.xml
new file mode 100644
index 000000000000..212a01ffd986
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/maven-no-lockfile/pom.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+	xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+	<modelVersion>4.0.0</modelVersion>
+	<groupId>com.example</groupId>
+	<artifactId>example</artifactId>
+	<version>0.0.1-SNAPSHOT</version>
+	<name>example</name>
+	<url/>
+	<properties>
+		<java.version>22</java.version>
+	</properties>
+    <dependencies>
+        <dependency>
+        <groupId>org.apache.logging.log4j</groupId>
+        <artifactId>log4j-core</artifactId>
+        <version>2.15.0</version>
+        </dependency>
+    </dependencies>
+
+	<build>
+		<plugins>
+			<plugin>
+				<groupId>org.springframework.boot</groupId>
+				<artifactId>spring-boot-maven-plugin</artifactId>
+			</plugin>
+		</plugins>
+	</build>
+
+</project>
diff --git a/cli/tests/default/e2e/targets/dependency_aware/swiftpm/v3/Package.resolved b/cli/tests/default/e2e/targets/dependency_aware/swiftpm/v3/Package.resolved
new file mode 100644
index 000000000000..661f7684302d
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/swiftpm/v3/Package.resolved
@@ -0,0 +1,42 @@
+{
+  "originHash" : "abcdfaked83f1c1c6fbffb2de68eeb72f9d3911e7f3c3159ca17ee57f4faae9f",
+  "pins" : [
+    {
+      "identity" : "swift-collections",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-collections.git",
+      "state" : {
+        "revision" : "d029d9d39c87bed85b1c50adee7c41795261a192",
+        "version" : "1.0.6"
+      }
+    },
+    {
+      "identity" : "swift-docc-plugin",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-docc-plugin",
+      "state" : {
+        "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64",
+        "version" : "1.4.3"
+      }
+    },
+    {
+      "identity" : "swift-docc-symbolkit",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/swiftlang/swift-docc-symbolkit",
+      "state" : {
+        "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
+        "version" : "1.0.0"
+      }
+    },
+    {
+      "identity" : "swift-system",
+      "kind" : "remoteSourceControl",
+      "location" : "https://github.com/apple/swift-system",
+      "state" : {
+        "revision" : "c8a44d836fe7913603e246acab7c528c2e780168",
+        "version" : "1.4.0"
+      }
+    }
+  ],
+  "version" : 3
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/targets/dependency_aware/swiftpm/v3/Package.swift b/cli/tests/default/e2e/targets/dependency_aware/swiftpm/v3/Package.swift
new file mode 100644
index 000000000000..89f6e07fa9ed
--- /dev/null
+++ b/cli/tests/default/e2e/targets/dependency_aware/swiftpm/v3/Package.swift
@@ -0,0 +1,42 @@
+// swift-tools-version:6.0
+// The swift-tools-version declares the minimum version of Swift required to build this package.
+
+import PackageDescription
+
+let swiftSettings: Array<SwiftSetting> = [
+    .swiftLanguageMode(.v6),
+    .enableUpcomingFeature("ExistentialAny"),
+    .enableUpcomingFeature("InternalImportsByDefault"),
+    .enableExperimentalFeature("GlobalConcurrency"),
+]
+
+let package = Package(
+    name: "abc",
+    platforms: [
+        // These are necessary even though we don't support them.
+        .macOS(.v10_15),
+        .iOS(.v13),
+        .tvOS(.v13),
+        .watchOS(.v6),
+    ],
+    products: [
+        // Products define the executables and libraries a package produces, and make them visible to other packages.
+        .library(
+            name: "abc",
+            targets: ["abc"]),
+    ],
+    dependencies: [
+        // Dependencies declare other packages that this package depends on.
+        .package(url: "https://github.com/apple/swift-docc-plugin", from: "1.0.0"),
+        .package(url: "https://github.com/apple/swift-system", from: "1.2.0"),
+    ],
+    targets: [
+        // Targets are the basic building blocks of a package. A target can define a module or a test suite.
+        // Targets can depend on other targets in this package, and on products in packages this package depends on.
+        .target
+            name: "abc"),
+        .testTarget(
+            name: "abcTests",
+            dependencies: ["abc"]),
+    ]
+)
\ No newline at end of file
diff --git a/cli/tests/default/e2e/test_osemgrep_output.py b/cli/tests/default/e2e/test_osemgrep_output.py
deleted file mode 100644
index 6023b97288a5..000000000000
--- a/cli/tests/default/e2e/test_osemgrep_output.py
+++ /dev/null
@@ -1,51 +0,0 @@
-from typing import List
-from typing import Optional
-
-import pytest
-from tests.fixtures import RunSemgrep
-
-from semgrep.constants import OutputFormat
-
-
-@pytest.mark.kinda_slow
-@pytest.mark.parametrize("output_format", [OutputFormat.SARIF])
-@pytest.mark.parametrize(
-    "rule_and_target",
-    [
-        # Simple case that should pass.
-        ("rules/eqeq.yaml", "basic/stupid.py"),
-        # Whenever there's a CWE tag, there should be a security tag.
-        ("rules/cwe_tag.yaml", "basic/stupid.py"),
-        # Rules with metavariable-type need parser initialization to parse correctly.
-        ("rules/metavariable_type.yaml", "basic/stupid.py"),
-        # Dataflow traces in SARIF should abide by --dataflow-traces.
-        ("rules/taint_trace.yaml", "taint/taint_trace.cpp"),
-    ],
-)
-@pytest.mark.parametrize("dataflow_traces", [True, False])
-def test_sarif_output(
-    run_semgrep_in_tmp: RunSemgrep, output_format, rule_and_target, dataflow_traces
-):
-    rule, target = rule_and_target
-    # The type annotation is there to make the type checker happy
-    options: Optional[List[str]]
-    if dataflow_traces:
-        options = ["--verbose", "--use-osemgrep-sarif", "--dataflow-traces"]
-    else:
-        options = ["--verbose", "--use-osemgrep-sarif"]
-
-    _out, err = run_semgrep_in_tmp(
-        rule,
-        target_name=target,
-        options=options,
-        output_format=output_format,
-        assert_exit_code=0,
-    )
-    # When --verbose is enabled, we log
-    #   "Osemgrep vs Pysemgrep SARIF output mismatch."
-    # when osemgrep and pysemgrep outputs don't match.
-    #
-    # See an example log in osemgrep_sarif.py. For other
-    # output formats, we should log this consistently
-    # for it to be tested here.
-    assert "output mismatch" not in err
diff --git a/cli/tests/default/e2e/test_output_sarif.py b/cli/tests/default/e2e/test_output_sarif.py
index f76708c89df9..233afac4faf3 100644
--- a/cli/tests/default/e2e/test_output_sarif.py
+++ b/cli/tests/default/e2e/test_output_sarif.py
@@ -6,8 +6,70 @@
 from semgrep.constants import OutputFormat
 
 
-# If there are nosemgrep comments to ignore findings, SARIF output should include them
-# labeled as suppressed.
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize(
+    "rule_and_target",
+    [
+        # Simple case that should pass.
+        ("rules/eqeq.yaml", "basic/stupid.py"),
+        # Whenever there's a CWE tag, there should be a security tag.
+        ("rules/cwe_tag.yaml", "basic/stupid.py"),
+        # Rules with metavariable-type need parser initialization to parse correctly.
+        ("rules/metavariable_type.yaml", "basic/stupid.py"),
+    ],
+)
+@pytest.mark.parametrize("dataflow_traces", [True, False])
+def test_sarif_output(
+    run_semgrep_in_tmp: RunSemgrep, snapshot, rule_and_target, dataflow_traces
+):
+    rule, target = rule_and_target
+    if dataflow_traces:
+        options = ["--verbose", "--dataflow-traces"]
+    else:
+        options = ["--verbose"]
+
+    res = run_semgrep_in_tmp(
+        rule,
+        target_name=target,
+        options=options,
+        output_format=OutputFormat.SARIF,
+        assert_exit_code=0,
+    )
+    snapshot.assert_match(res.stdout, "results.sarif")
+
+
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize(
+    "rule_and_target",
+    [
+        # TODO: osemgrep does not take into account labels
+        # and the rule.py formula_string() is not fully ported
+        ("rules/taint_trace.yaml", "taint/taint_trace.cpp"),
+    ],
+)
+@pytest.mark.parametrize("dataflow_traces", [True, False])
+@pytest.mark.osemfail
+def test_sarif_output_osemfail(
+    run_semgrep_in_tmp: RunSemgrep, snapshot, rule_and_target, dataflow_traces
+):
+    rule, target = rule_and_target
+    if dataflow_traces:
+        options = ["--verbose", "--dataflow-traces"]
+    else:
+        options = ["--verbose"]
+
+    res = run_semgrep_in_tmp(
+        rule,
+        target_name=target,
+        options=options,
+        output_format=OutputFormat.SARIF,
+        assert_exit_code=0,
+    )
+    snapshot.assert_match(res.stdout, "results.sarif")
+
+
+# If there are nosemgrep comments to ignore findings, SARIF output should
+# include them labeled as suppressed.
 @pytest.mark.kinda_slow
 def test_sarif_output_include_nosemgrep(run_semgrep_in_tmp: RunSemgrep, snapshot):
     snapshot.assert_match(
diff --git a/cli/tests/default/e2e/test_target_selection.py b/cli/tests/default/e2e/test_target_selection.py
index 9ff12301d506..9c9df0f69810 100644
--- a/cli/tests/default/e2e/test_target_selection.py
+++ b/cli/tests/default/e2e/test_target_selection.py
@@ -6,16 +6,48 @@
 import sys
 from dataclasses import dataclass
 from dataclasses import field
+from enum import Enum
 from pathlib import Path
 from typing import List
 from typing import Optional
 from typing import Set
-from typing import Tuple
 
 import pytest
 from tests.fixtures import RunSemgrep
 
 
+# Specify how to download the test project
+@dataclass
+class GitProject:
+    name: str
+    # The git project URL (not the web page)
+    url: str
+    # SHA1 commit ID
+    commit: str
+
+
+# Identify how the repo should be set up and scanned by semgrep
+class Config(Enum):
+    # git: a git project scanned with the default semgrep options
+    GIT = "git"
+
+    # novcs: the same project from which '.git' was removed, scanned with
+    # the default semgrep options
+    NOVCS = "novcs"
+
+    # ignoregit: run on the git project with the '--no-git-ignore' option
+    # (does several things that are not obvious from its name)
+    IGNOREGIT = "ignoregit"
+
+    # default ignores: test a project that doesn't have .semgrepignore files
+    GIT_DEFAULT_SEMGREPIGNORE = "git_default_semgrepignore"
+    NOVCS_DEFAULT_SEMGREPIGNORE = "novcs_default_semgrepignore"
+
+    # test a blank .semgrepignore
+    GIT_EMPTY_SEMGREPIGNORE = "git_empty_semgrepignore"
+    NOVCS_EMPTY_SEMGREPIGNORE = "novcs_empty_semgrepignore"
+
+
 # The expectations regarding a particular target file path
 @dataclass
 class Expect:
@@ -27,12 +59,30 @@ class Expect:
     paths: List[str] = field(default_factory=lambda: [])
 
 
+# This is an artificial git project that offers all the difficulties we could
+# think of for file targeting.
+#
+# Do we need to run on several projects?
+PROJECT = GitProject(
+    name="semgrep-test-project1",
+    url="https://github.com/semgrep/semgrep-test-project1.git",
+    commit="adecf8260eb7dac06c9257d3648d879584e94e60",
+)
+
+
+def is_git_project(config: Config) -> bool:
+    if config is Config.GIT or config is Config.IGNOREGIT:
+        return True
+    else:
+        return False
+
+
 # Check whether a target path was selected or ignored by semgrep, depending
 # the expectation we have.
 def check_expectation(
     expect: Expect,
     is_running_osemgrep: bool,
-    is_git_project: bool,
+    config: Config,
     selected_targets: Set[str],
 ):
     paths = expect.paths
@@ -49,8 +99,12 @@ def check_expectation(
         expect_selected = expect.selected_by_pysemgrep
 
     label = "[osemgrep]" if is_running_osemgrep else "[pysemgrep]"
-    label = label + (" [git project]" if is_git_project else " [nongit project]")
+    label = label + (f" [{config.value}]")
     for path in paths:
+        # Sanity checks (important when checking that a path is not selected)
+        if not os.path.lexists(path):
+            raise Exception(f"path {path} doesn't exist in the file system!")
+        # Tests
         if expect_selected:
             print(
                 f"{label} check that target path was selected: {path}", file=sys.stderr
@@ -68,7 +122,7 @@ def check_expectation(
 # - run semgrep from the project root
 # - run semgrep on the project root
 # - no optional command-line flags
-# - shared expectations in git and nongit projects
+# - shared expectations in git and novcs projects
 #
 # To cover a new test case, add a file to the test repo and specify
 # the expectations for the new path below.
@@ -145,7 +199,7 @@ def check_expectation(
 ]
 
 GIT_PROJECT_EXPECTATIONS = [
-    # common expectations for a git project (but not for a nongit project)
+    # common expectations for a git project (but not for a novcs project)
     Expect(
         selected=False,
         paths=[
@@ -168,14 +222,14 @@ def check_expectation(
     ),
 ]
 
-NONGIT_PROJECT_EXPECTATIONS = [
-    # common expectations for a nongit project (but not for a git project)
+NOVCS_PROJECT_EXPECTATIONS = [
+    # common expectations for a novcs project (but not for a git project)
     Expect(
         selected=True,
         paths=[
             # regular file in what was a git submodule
             "submodules/semgrep-test-project2/hello.py",
-            # we don't consult .gitignore files in nongit projects
+            # we don't consult .gitignore files in novcs projects
             "src/gitignored.py",
             "src/gitignored-only-in-src-and-below.py",
             "src/gitignored-only-in-src.py",
@@ -190,52 +244,190 @@ def check_expectation(
     ),
 ]
 
-# This is an artificial git project that offers all the difficulties we could
-# think of for file targeting.
-#
-# Do we need to run on several projects?
-PROJECT: Tuple[str, str] = (
-    "semgrep-test-project1",
-    "https://github.com/semgrep/semgrep-test-project1.git",
-)
+GIT_DEFAULT_SEMGREPIGNORE_EXPECTATIONS = [
+    Expect(
+        selected=True,
+        paths=[
+            # sanity check
+            "hello.py",
+            # this is not ignored by the default semgrepignore patterns
+            "semgrepignored/hello.py",
+        ],
+    ),
+    Expect(
+        selected=False,
+        paths=[
+            # .git is excluded by 'git ls-files'
+            ".git/HEAD",
+            # tests/ is excluded by the default semgrepignore patterns
+            "tests/hello.py",
+            # git submodules are ignored
+            "submodules/semgrep-test-project2/hello.py",
+        ],
+    ),
+]
+
+NOVCS_DEFAULT_SEMGREPIGNORE_EXPECTATIONS = [
+    Expect(
+        selected=True,
+        paths=[
+            # sanity check
+            "hello.py",
+            # this is not ignored by the default semgrepignore patterns
+            "semgrepignored/hello.py",
+            # git submodules are not ignored
+            "submodules/semgrep-test-project2/hello.py",
+            # this is not ignored by the default semgrepignore patterns
+            "submodules/semgrep-test-project2/semgrepignored/hello.py",
+        ],
+    ),
+    Expect(
+        selected=False,
+        paths=[
+            # any .git file is excluded by the default semgrepignore patterns
+            "submodules/semgrep-test-project2/.git",
+            # tests/ is excluded by the default semgrepignore patterns
+            "tests/hello.py",
+            "submodules/semgrep-test-project2/tests/hello.py",
+        ],
+    ),
+]
+
+GIT_EMPTY_SEMGREPIGNORE_EXPECTATIONS = [
+    Expect(
+        selected=True,
+        paths=[
+            "hello.py",
+            "semgrepignored/hello.py",
+            "tests/hello.py",
+        ],
+    ),
+    Expect(
+        selected=False,
+        paths=[
+            # always excluded by git
+            ".git/HEAD",
+            # submodule, excluded by git
+            "submodules/semgrep-test-project2/hello.py",
+        ],
+    ),
+]
+
+NOVCS_EMPTY_SEMGREPIGNORE_EXPECTATIONS = [
+    Expect(
+        selected=True,
+        paths=[
+            "hello.py",
+            "semgrepignored/hello.py",
+            "tests/hello.py",
+            "submodules/semgrep-test-project2/hello.py",
+            "submodules/semgrep-test-project2/semgrepignored/hello.py",
+            "submodules/semgrep-test-project2/tests/hello.py",
+        ],
+    ),
+    # pysemgrep bugs
+    Expect(
+        selected=True,
+        selected_by_pysemgrep=False,
+        paths=[
+            "submodules/semgrep-test-project2/.git",
+        ],
+    ),
+]
 
 
 @pytest.mark.kinda_slow
 @pytest.mark.parametrize(
     # a list of extra semgrep CLI options and osemgrep-specific options
-    "is_git_project,options,osemgrep_options,expectations",
+    "config,options,osemgrep_options,expectations",
     [
-        (True, [], [], COMMON_EXPECTATIONS + GIT_PROJECT_EXPECTATIONS),
-        (False, [], [], COMMON_EXPECTATIONS + NONGIT_PROJECT_EXPECTATIONS),
+        (Config.GIT, [], [], COMMON_EXPECTATIONS + GIT_PROJECT_EXPECTATIONS),
+        (Config.NOVCS, [], [], COMMON_EXPECTATIONS + NOVCS_PROJECT_EXPECTATIONS),
+        (
+            Config.IGNOREGIT,
+            ["--no-git-ignore"],
+            [],
+            COMMON_EXPECTATIONS + NOVCS_PROJECT_EXPECTATIONS,
+        ),
+        (
+            Config.GIT_DEFAULT_SEMGREPIGNORE,
+            [],
+            [],
+            GIT_DEFAULT_SEMGREPIGNORE_EXPECTATIONS,
+        ),
+        (
+            Config.NOVCS_DEFAULT_SEMGREPIGNORE,
+            [],
+            [],
+            NOVCS_DEFAULT_SEMGREPIGNORE_EXPECTATIONS,
+        ),
+        (Config.GIT_EMPTY_SEMGREPIGNORE, [], [], GIT_EMPTY_SEMGREPIGNORE_EXPECTATIONS),
+        (
+            Config.NOVCS_EMPTY_SEMGREPIGNORE,
+            [],
+            [],
+            NOVCS_EMPTY_SEMGREPIGNORE_EXPECTATIONS,
+        ),
+    ],
+    ids=[
+        Config.GIT.value,
+        Config.NOVCS.value,
+        Config.IGNOREGIT.value,
+        Config.GIT_DEFAULT_SEMGREPIGNORE.value,
+        Config.NOVCS_DEFAULT_SEMGREPIGNORE.value,
+        Config.GIT_EMPTY_SEMGREPIGNORE.value,
+        Config.NOVCS_EMPTY_SEMGREPIGNORE.value,
     ],
-    ids=["git", "nongit"],
 )
 def test_project_target_selection(
     monkeypatch: pytest.MonkeyPatch,
     tmp_path: Path,
     run_semgrep: RunSemgrep,
-    is_git_project: bool,
+    config: Config,
     options: List[str],
     osemgrep_options: List[str],
     expectations: List[Expect],
 ) -> None:
+    project = PROJECT
     # Instead of copying or symlinking the git submodule that sits nicely
     # in our semgrep repo, we clone it as standalone repo to avoid problems
     # due to having the structure of a submodule but no parent git project.
-    project_name, project_url = PROJECT
     print(f"cd into {tmp_path}", file=sys.stderr)
     monkeypatch.chdir(tmp_path)
-    print(f"clone {project_url}", file=sys.stderr)
-    os.system(f"git clone {project_url} {project_name}")
-    print(f"cd into {project_name}", file=sys.stderr)
-    monkeypatch.chdir(Path(project_name))
+    print(f"clone {project.url}", file=sys.stderr)
+    os.system(f"git clone {project.url} {project.name}")
+    print(f"cd into {project.name}", file=sys.stderr)
+    monkeypatch.chdir(Path(project.name))
+    print(f"check out commit {project.commit}", file=sys.stderr)
+    os.system(f"git checkout {project.commit}")
     print(f"check out submodules", file=sys.stderr)
     os.system(f"git submodule update --init --recursive")
 
-    if not is_git_project:
-        print(f"remove .git to make this a non-git project", file=sys.stderr)
+    if (
+        config is Config.NOVCS
+        or config is Config.NOVCS_DEFAULT_SEMGREPIGNORE
+        or config is Config.NOVCS_EMPTY_SEMGREPIGNORE
+    ):
+        print(f"remove .git to make this a no-VCS project", file=sys.stderr)
         shutil.rmtree(".git")
 
+    if (
+        config is Config.GIT_DEFAULT_SEMGREPIGNORE
+        or config is Config.NOVCS_DEFAULT_SEMGREPIGNORE
+        or config is Config.GIT_EMPTY_SEMGREPIGNORE
+        or config is Config.NOVCS_EMPTY_SEMGREPIGNORE
+    ):
+        print(f"remove .semgrepignore files", file=sys.stderr)
+        os.remove(".semgrepignore")
+        os.remove("src/.semgrepignore")
+        os.remove("submodules/semgrep-test-project2/.semgrepignore")
+        if (
+            config is Config.GIT_EMPTY_SEMGREPIGNORE
+            or config is Config.NOVCS_EMPTY_SEMGREPIGNORE
+        ):
+            print(f"create an empty .semgrepignore", file=sys.stderr)
+            open(".semgrepignore", "w").close()
+
     is_running_osemgrep = True if os.environ.get("PYTEST_USE_OSEMGREP") else False
 
     extra_options = options
@@ -259,4 +451,4 @@ def test_project_target_selection(
 
     # Check the status of each file path we want to check.
     for expect in expectations:
-        check_expectation(expect, is_running_osemgrep, is_git_project, selected_targets)
+        check_expectation(expect, is_running_osemgrep, config, selected_targets)
diff --git a/cli/tests/default/unit/test_formatters.py b/cli/tests/default/unit/test_formatters.py
deleted file mode 100644
index 772dd8c4a69d..000000000000
--- a/cli/tests/default/unit/test_formatters.py
+++ /dev/null
@@ -1,235 +0,0 @@
-import builtins
-from io import StringIO
-from pathlib import Path
-from textwrap import dedent
-
-import pytest
-from ruamel.yaml import YAML
-
-import semgrep.semgrep_interfaces.semgrep_output_v1 as out
-from semgrep.formatter.sarif import SarifFormatter
-from semgrep.rule import Rule
-from semgrep.rule_lang import EmptySpan
-from semgrep.rule_lang import YamlTree
-from semgrep.rule_match import RuleMatch
-
-yaml = YAML(typ="rt")
-
-
-def create_taint_rule_match():
-    match = RuleMatch(
-        message="message",
-        severity=out.MatchSeverity(out.Error()),
-        match=out.CoreMatch(
-            check_id=out.RuleId("rule.id"),
-            path=out.Fpath("foo.py"),
-            start=out.Position(3, 4, 6),
-            end=out.Position(3, 5, 7),
-            extra=out.CoreMatchExtra(
-                metavars=out.Metavars({}),
-                dataflow_trace=out.MatchDataflowTrace(
-                    taint_source=out.MatchCallTrace(
-                        out.CliLoc(
-                            out.LocAndContent(
-                                (
-                                    out.Location(
-                                        path=out.Fpath("foo.py"),
-                                        start=out.Position(8, 9, 11),
-                                        end=out.Position(8, 10, 12),
-                                    ),
-                                    "??",
-                                )
-                            )
-                        )
-                    ),
-                    intermediate_vars=[
-                        out.MatchIntermediateVar(
-                            location=out.Location(
-                                path=out.Fpath("foo.py"),
-                                start=out.Position(13, 14, 16),
-                                end=out.Position(13, 15, 17),
-                            ),
-                            content="??",
-                        )
-                    ],
-                    taint_sink=out.MatchCallTrace(
-                        out.CliLoc(
-                            out.LocAndContent(
-                                (
-                                    out.Location(
-                                        path=out.Fpath("foo.py"),
-                                        start=out.Position(15, 16, 20),
-                                        end=out.Position(15, 17, 21),
-                                    ),
-                                    "??",
-                                )
-                            )
-                        )
-                    ),
-                ),
-                engine_kind=out.EngineKind(out.OSS()),
-                is_ignored=False,
-            ),
-        ),
-    )
-    return match
-
-
-@pytest.mark.quick
-def test_dataflow_source_to_thread_flow_sarif(mocker):
-    # https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012707
-    file_content = dedent(
-        """
-        # first line
-        def foo():
-            5 == 5 # nosem
-        """
-    ).lstrip()
-    mocker.patch.object(Path, "open", mocker.mock_open(read_data=file_content))
-    mocker.patch.object(builtins, "open", mocker.mock_open(read_data=file_content))
-    taint_rule_match = create_taint_rule_match()
-    thread_flow_location = SarifFormatter._taint_source_to_thread_flow_location_sarif(
-        taint_rule_match
-    )
-
-    assert bool(thread_flow_location.get("location")), (
-        "If location information is available, a threadFlowLocation object "
-        "SHALL contain a property named location."
-    )
-    assert isinstance(thread_flow_location.get("location"), dict), (
-        "A location value is a location object that specifies the location to "
-        "which the threadFlowLocation object refers"
-    )
-
-
-@pytest.mark.quick
-def test_intermediate_vars_to_thread_flow_location_sarif(mocker):
-    # https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012707
-    file_content = dedent(
-        """
-        # first line
-        def foo():
-            5 == 5 # nosem
-        """
-    ).lstrip()
-    mocker.patch.object(Path, "open", mocker.mock_open(read_data=file_content))
-    mocker.patch.object(builtins, "open", mocker.mock_open(read_data=file_content))
-    taint_rule_match = create_taint_rule_match()
-    thread_flow_locations = (
-        SarifFormatter._intermediate_vars_to_thread_flow_location_sarif(
-            taint_rule_match
-        )
-    )
-
-    assert isinstance(thread_flow_locations, list), (
-        "A threadFlow object SHALL contain a property named locations whose "
-        "value is an array of one or more threadFlowLocation objects "
-    )
-
-    for thread_flow_location in thread_flow_locations:
-        assert bool(thread_flow_location.get("location")), (
-            "If location information is available, a threadFlowLocation object "
-            "SHALL contain a property named location."
-        )
-        assert isinstance(thread_flow_location.get("location"), dict), (
-            "A location value is a location object that specifies the location to "
-            "which the threadFlowLocation object refers"
-        )
-
-
-@pytest.mark.quick
-def test_sink_to_thread_flow_location_sarif(mocker):
-    # https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012707
-    file_content = dedent(
-        """
-        # first line
-        def foo():
-            5 == 5 # nosem
-        """
-    ).lstrip()
-    mocker.patch.object(Path, "open", mocker.mock_open(read_data=file_content))
-    mocker.patch.object(builtins, "open", mocker.mock_open(read_data=file_content))
-    taint_rule_match = create_taint_rule_match()
-    thread_flow_location = SarifFormatter._sink_to_thread_flow_location_sarif(
-        taint_rule_match
-    )
-
-    assert bool(thread_flow_location.get("location")), (
-        "If location information is available, a threadFlowLocation object "
-        "SHALL contain a property named location."
-    )
-    assert isinstance(thread_flow_location.get("location"), dict), (
-        "A location value is a location object that specifies the location to "
-        "which the threadFlowLocation object refers"
-    )
-
-
-@pytest.mark.quick
-def test_dataflow_trace_to_thread_flows_sarif(mocker):
-    # https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012699
-    file_content = dedent(
-        """
-        # first line
-        def foo():
-            5 == 5 # nosem
-        """
-    ).lstrip()
-    mocker.patch.object(Path, "open", mocker.mock_open(read_data=file_content))
-    mocker.patch.object(builtins, "open", mocker.mock_open(read_data=file_content))
-    taint_rule_match = create_taint_rule_match()
-    thread_flows = SarifFormatter._dataflow_trace_to_thread_flows_sarif(
-        taint_rule_match
-    )
-
-    for thread_flow in thread_flows:
-        assert bool(
-            thread_flow.get("locations")
-        ), "A threadFlow object SHALL contain a property named locations."
-        assert isinstance(
-            thread_flow.get("locations"), list
-        ), "A locations value is an array of one or more threadFlowLocation objects"
-
-
-@pytest.mark.quick
-def test_dataflow_trace_to_codeflow_sarif(mocker):
-    # https://docs.oasis-open.org/sarif/sarif/v2.1.0/cs01/sarif-v2.1.0-cs01.html#_Toc16012696
-    file_content = dedent(
-        """
-        # first line
-        def foo():
-            5 == 5 # nosem
-        """
-    ).lstrip()
-    mocker.patch.object(Path, "open", mocker.mock_open(read_data=file_content))
-    mocker.patch.object(builtins, "open", mocker.mock_open(read_data=file_content))
-    taint_rule_match = create_taint_rule_match()
-    code_flow = SarifFormatter._dataflow_trace_to_codeflow_sarif(taint_rule_match)
-
-    assert bool(
-        code_flow.get("threadFlows")
-    ), "A codeFlow object SHALL contain a property named threadFlows"
-    assert isinstance(
-        code_flow.get("threadFlows"), list
-    ), "A threadFlows value is an array of one or more threadFlow objects"
-
-
-@pytest.mark.quick
-def test_rule_to_sarif_tags():
-    r = """
-      id: blah
-      languages: [js]
-      severity: INFO
-      message: blah
-      pattern: blah(...)
-      metadata:
-        cwe:
-        - CWE-22
-        owasp:
-        - A01:2021
-    """
-    with StringIO(r) as stream:
-        j = yaml.load(stream)
-    rule = Rule.from_yamltree(YamlTree.wrap(j, EmptySpan))
-    tags = SarifFormatter._rule_to_sarif_tags(rule)
-
-    assert all([isinstance(tag, str) for tag in tags])
diff --git a/cli/tests/default/unit/test_resolve_subprojects.py b/cli/tests/default/unit/test_resolve_subprojects.py
index c8dd248ba730..5e4ae643268c 100644
--- a/cli/tests/default/unit/test_resolve_subprojects.py
+++ b/cli/tests/default/unit/test_resolve_subprojects.py
@@ -8,7 +8,7 @@
 from semdep.subproject_matchers import ExactLockfileManifestMatcher
 from semdep.subproject_matchers import ExactManifestOnlyMatcher
 from semdep.subproject_matchers import SubprojectMatcher
-from semgrep.resolve_subprojects import _resolve_dependency_source
+from semgrep.resolve_dependency_source import resolve_dependency_source
 from semgrep.resolve_subprojects import find_subprojects
 from semgrep.subproject import LockfileOnlyDependencySource
 from semgrep.subproject import ManifestLockfileDependencySource
@@ -160,7 +160,7 @@ def test_find_subprojects(
 
 
 @pytest.mark.quick
-@patch("semgrep.resolve_subprojects._resolve_dependencies_dynamically")
+@patch("semgrep.resolve_dependency_source._resolve_dependencies_dynamically")
 def test_ptt_unconditionally_generates_dependency_graphs(
     mock_dynamic_resolve, tmp_path: Path
 ) -> None:
@@ -182,7 +182,7 @@ def test_ptt_unconditionally_generates_dependency_graphs(
             out.Fpath(str(tmp_path / "requirements.txt")),
         ),
     )
-    _resolve_dependency_source(dep_source, True, True)
+    resolve_dependency_source(dep_source, True, True)
 
     mock_dynamic_resolve.mock_assert_called_once_with(
         Path("requirements.txt"), out.ManifestKind(value=out.RequirementsIn())
@@ -190,7 +190,7 @@ def test_ptt_unconditionally_generates_dependency_graphs(
 
 
 @pytest.mark.quick
-@patch("semgrep.resolve_subprojects._resolve_dependencies_dynamically")
+@patch("semgrep.resolve_dependency_source._resolve_dependencies_dynamically")
 @patch("semdep.parsers.requirements.parse_requirements")
 def test_ptt_unconditional_graph_generation_falls_back_on_lockfile_parsing(
     mock_dynamic_resolve, mock_parse_requirements, tmp_path: Path
@@ -225,7 +225,7 @@ def test_ptt_unconditional_graph_generation_falls_back_on_lockfile_parsing(
             out.Fpath(str(tmp_path / "requirements.txt")),
         ),
     )
-    _resolve_dependency_source(dep_source, True, True)
+    resolve_dependency_source(dep_source, True, True)
 
     mock_parse_requirements.mock_assert_called_once_with(
         Path(tmp_path / "requirements.txt"), Path(tmp_path / "requirements.in")
diff --git a/cli/tests/default/unit/test_subproject.py b/cli/tests/default/unit/test_subproject.py
index 82697646a607..260a18eff1ae 100644
--- a/cli/tests/default/unit/test_subproject.py
+++ b/cli/tests/default/unit/test_subproject.py
@@ -1,3 +1,4 @@
+import hashlib
 from pathlib import Path
 
 import pytest
@@ -13,10 +14,12 @@
 from semgrep.subproject import find_closest_subproject
 from semgrep.subproject import LockfileOnlyDependencySource
 from semgrep.subproject import ManifestLockfileDependencySource
+from semgrep.subproject import ManifestOnlyDependencySource
 from semgrep.subproject import MultiLockfileDependencySource
 from semgrep.subproject import ResolutionMethod
 from semgrep.subproject import ResolvedDependencies
 from semgrep.subproject import ResolvedSubproject
+from semgrep.subproject import Subproject
 
 
 def create_tmp_file(path: Path):
@@ -98,7 +101,7 @@ def test_finds_subproject_for_requested_ecosystem(self, tmp_path, monkeypatch):
                     out.Fpath("a/b/build.gradle"),
                 ),
                 lockfile=out.Lockfile(
-                    out.LockfileKind(out.GradleLockfile_()),
+                    out.LockfileKind(out.GradleLockfile()),
                     out.Fpath(str(lockfile_path)),
                 ),
             ),
@@ -289,27 +292,112 @@ def test_dep_missing_lockfile_path(self):
             lockfile_path
         ], "Should return lockfile path"
 
+    @pytest.mark.quick
+    def test_to_stats_output(self):
+        dependency_source = LockfileOnlyDependencySource(
+            lockfile=out.Lockfile(
+                out.LockfileKind(out.PipRequirementsTxt()),
+                out.Fpath("a/b/c/requirements.txt"),
+            ),
+        )
+
+        subproject = Subproject(
+            root_dir=Path("a/b/c"),
+            dependency_source=dependency_source,
+        )
+
+        subproject_id = hashlib.sha256(
+            str(dependency_source.lockfile.path.value).encode("utf-8")
+        ).hexdigest()
+
+        assert subproject.to_stats_output() == out.SubprojectStats(
+            subproject_id=subproject_id,
+            dependency_sources=dependency_source.to_stats_output(),
+            resolved_stats=None,
+        )
 
-class TestLockfileDependencySource:
+
+class TestResolvedSubproject:
     @pytest.mark.quick
-    def test_base_case(self):
+    def test_to_stats_output(self):
         lockfile_path = Path("a/b/c/requirements.txt")
-
-        source = LockfileOnlyDependencySource(
+        dependency_source = LockfileOnlyDependencySource(
             lockfile=out.Lockfile(
                 out.LockfileKind(out.PipRequirementsTxt()),
                 out.Fpath(str(lockfile_path)),
-            )
+            ),
+        )
+        ecosystem = Ecosystem(Pypi())
+
+        subproject = ResolvedSubproject(
+            root_dir=Path("a/b/c"),
+            resolution_errors=[],
+            dependency_source=dependency_source,
+            resolution_method=ResolutionMethod.LOCKFILE_PARSING,
+            ecosystem=ecosystem,
+            found_dependencies=ResolvedDependencies.from_found_dependencies([]),
+        )
+
+        subproject_id = hashlib.sha256(str(lockfile_path).encode("utf-8")).hexdigest()
+
+        assert subproject.to_stats_output() == out.SubprojectStats(
+            subproject_id=subproject_id,
+            dependency_sources=dependency_source.to_stats_output(),
+            resolved_stats=out.DependencyResolutionStats(
+                resolution_method=ResolutionMethod.LOCKFILE_PARSING.to_stats_output(),
+                dependency_count=0,
+                ecosystem=ecosystem,
+            ),
+        )
+
+
+class TestLockfileOnlyDependencySource:
+    @pytest.fixture
+    def lockfile_source(self):
+        lockfile_path = Path("a/b/c/requirements.txt")
+        return (
+            lockfile_path,
+            LockfileOnlyDependencySource(
+                lockfile=out.Lockfile(
+                    out.LockfileKind(out.PipRequirementsTxt()),
+                    out.Fpath(str(lockfile_path)),
+                )
+            ),
         )
 
+    @pytest.mark.quick
+    def test_base_case(self, lockfile_source):
+        lockfile_path, source = lockfile_source
         assert source.get_display_paths() == [
             lockfile_path
         ], "Should return lockfile path"
 
+    @pytest.mark.quick
+    def test_to_semgrep_output(self, lockfile_source):
+        _, source = lockfile_source
+        assert source.to_semgrep_output() == out.DependencySource(
+            out.LockfileOnlyDependencySource(source.lockfile)
+        )
 
-class TestMultiLockfileDependencySource:
     @pytest.mark.quick
-    def test_base_case(self):
+    def test_to_stats_output(self, lockfile_source):
+        lockfile_path, source = lockfile_source
+
+        assert source.to_stats_output() == [
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Lockfile_(
+                        value=out.LockfileKind(out.PipRequirementsTxt())
+                    )
+                ),
+                path=out.Fpath(str(lockfile_path)),
+            )
+        ]
+
+
+class TestMultiLockfileDependencySource:
+    @pytest.fixture
+    def multi_lockfile_source(self):
         lockfile_path = Path("a/b/c/requirements.txt")
         extra_lockfile_path = Path("a/b/requirements/dev.txt")
 
@@ -323,14 +411,151 @@ def test_base_case(self):
                 ),
                 LockfileOnlyDependencySource(
                     lockfile=out.Lockfile(
-                        out.LockfileKind(out.PipRequirementsTxt()),
+                        out.LockfileKind(out.PoetryLock()),
                         out.Fpath(str(extra_lockfile_path)),
                     )
                 ),
             )
         )
 
+        return (
+            lockfile_path,
+            extra_lockfile_path,
+            source,
+        )
+
+    @pytest.mark.quick
+    def test_base_case(self, multi_lockfile_source):
+        lockfile_path, extra_lockfile_path, source = multi_lockfile_source
+
         assert source.get_display_paths() == [
             lockfile_path,
             extra_lockfile_path,
         ], "Should return lockfile paths"
+
+    @pytest.mark.quick
+    def test_to_stats_output(self, multi_lockfile_source):
+        lockfile_path, extra_lockfile_path, source = multi_lockfile_source
+
+        assert source.to_stats_output() == [
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Lockfile_(
+                        value=out.LockfileKind(out.PipRequirementsTxt())
+                    )
+                ),
+                path=out.Fpath(str(lockfile_path)),
+            ),
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Lockfile_(value=out.LockfileKind(out.PoetryLock()))
+                ),
+                path=out.Fpath(str(extra_lockfile_path)),
+            ),
+        ]
+
+
+class TestManifestOnlyDependencySource:
+    @pytest.fixture
+    def manifest_source(self):
+        manifest_path = Path("a/b/c/pyproject.toml")
+        return (
+            manifest_path,
+            ManifestOnlyDependencySource(
+                manifest=out.Manifest(
+                    out.ManifestKind(out.PyprojectToml()),
+                    out.Fpath(str(manifest_path)),
+                )
+            ),
+        )
+
+    @pytest.mark.quick
+    def test_base_case(self, manifest_source):
+        manifest_path, source = manifest_source
+        assert source.get_display_paths() == [
+            manifest_path
+        ], "Should return manifest path"
+
+    @pytest.mark.quick
+    def test_to_semgrep_output(self, manifest_source):
+        _, source = manifest_source
+        assert source.to_semgrep_output() == out.DependencySource(
+            out.ManifestOnlyDependencySource(source.manifest)
+        )
+
+    @pytest.mark.quick
+    def test_to_stats_output(self, manifest_source):
+        manifest_path, source = manifest_source
+        assert source.to_stats_output() == [
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Manifest_(value=out.ManifestKind(out.PyprojectToml()))
+                ),
+                path=out.Fpath(str(manifest_path)),
+            )
+        ]
+
+
+class TestManifestLockfileDependencySource:
+    @pytest.fixture
+    def manifest_lockfile_source(self):
+        manifest_path = Path("a/b/c/pyproject.toml")
+        lockfile_path = Path("a/b/c/poetry.lock")
+        return (
+            manifest_path,
+            lockfile_path,
+            ManifestLockfileDependencySource(
+                manifest=out.Manifest(
+                    out.ManifestKind(out.PyprojectToml()),
+                    out.Fpath(str(manifest_path)),
+                ),
+                lockfile=out.Lockfile(
+                    out.LockfileKind(out.PoetryLock()),
+                    out.Fpath(str(lockfile_path)),
+                ),
+            ),
+        )
+
+    @pytest.mark.quick
+    def test_base_case(self, manifest_lockfile_source):
+        _, lockfile_path, source = manifest_lockfile_source
+        assert source.get_display_paths() == [
+            lockfile_path
+        ], "Should return lockfile path"
+
+    @pytest.mark.quick
+    def test_to_semgrep_output(self, manifest_lockfile_source):
+        _, _, source = manifest_lockfile_source
+        assert source.to_semgrep_output() == out.DependencySource(
+            out.ManifestLockfileDependencySource((source.manifest, source.lockfile))
+        )
+
+    @pytest.mark.quick
+    def test_to_stats_output(self, manifest_lockfile_source):
+        manifest_path, lockfile_path, source = manifest_lockfile_source
+        assert source.to_stats_output() == [
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Lockfile_(value=out.LockfileKind(out.PoetryLock()))
+                ),
+                path=out.Fpath(str(lockfile_path)),
+            ),
+            out.DependencySourceFile(
+                kind=out.DependencySourceFileKind(
+                    value=out.Manifest_(value=out.ManifestKind(out.PyprojectToml()))
+                ),
+                path=out.Fpath(str(manifest_path)),
+            ),
+        ]
+
+
+class TestResolutionMethod:
+    @pytest.mark.quick
+    def test_to_stats_output(self):
+        assert (
+            ResolutionMethod.LOCKFILE_PARSING.to_stats_output()
+            == out.ResolutionMethod(out.LockfileParsing())
+        )
+        assert ResolutionMethod.DYNAMIC.to_stats_output() == out.ResolutionMethod(
+            out.DynamicResolution()
+        )
diff --git a/cli/tests/default/unit/test_subproject_matchers.py b/cli/tests/default/unit/test_subproject_matchers.py
index d694bcfdc444..5ec09e741fd2 100644
--- a/cli/tests/default/unit/test_subproject_matchers.py
+++ b/cli/tests/default/unit/test_subproject_matchers.py
@@ -22,7 +22,7 @@ def test_lockfile_match(self):
         matcher = ExactLockfileManifestMatcher(
             lockfile_name="Pipfile.lock",
             manifest_name="Pipfile",
-            manifest_kind=out.ManifestKind(value=out.Pipfile_()),
+            manifest_kind=out.ManifestKind(value=out.Pipfile()),
             lockfile_kind=out.LockfileKind(value=out.PipfileLock()),
         )
 
@@ -35,7 +35,7 @@ def test_manifest_match(self):
         matcher = ExactLockfileManifestMatcher(
             lockfile_name="Pipfile.lock",
             manifest_name="Pipfile",
-            manifest_kind=out.ManifestKind(value=out.Pipfile_()),
+            manifest_kind=out.ManifestKind(value=out.Pipfile()),
             lockfile_kind=out.LockfileKind(value=out.PipfileLock()),
         )
 
@@ -56,7 +56,7 @@ def test_make_subprojects(
         matcher = ExactLockfileManifestMatcher(
             lockfile_name="Pipfile.lock",
             manifest_name="Pipfile",
-            manifest_kind=out.ManifestKind(value=out.Pipfile_()),
+            manifest_kind=out.ManifestKind(value=out.Pipfile()),
             lockfile_kind=out.LockfileKind(value=out.PipfileLock()),
         )
 
@@ -607,7 +607,7 @@ def test_is_match(self, path: Path, expected_match: bool) -> None:
                                 path=out.Fpath("build.gradle"),
                             ),
                             lockfile=out.Lockfile(
-                                out.LockfileKind(out.GradleLockfile_()),
+                                out.LockfileKind(out.GradleLockfile()),
                                 out.Fpath("gradle.lockfile"),
                             ),
                         ),
@@ -651,7 +651,7 @@ def test_is_match(self, path: Path, expected_match: bool) -> None:
                         root_dir=Path("buildSrc"),
                         dependency_source=ManifestLockfileDependencySource(
                             lockfile=out.Lockfile(
-                                kind=out.LockfileKind(out.GradleLockfile_()),
+                                kind=out.LockfileKind(out.GradleLockfile()),
                                 path=out.Fpath("buildSrc/gradle.lockfile"),
                             ),
                             manifest=out.Manifest(
@@ -685,7 +685,7 @@ def test_is_match(self, path: Path, expected_match: bool) -> None:
                                 path=out.Fpath("build.gradle"),
                             ),
                             lockfile=out.Lockfile(
-                                out.LockfileKind(out.GradleLockfile_()),
+                                out.LockfileKind(out.GradleLockfile()),
                                 out.Fpath("gradle.lockfile"),
                             ),
                         ),
@@ -705,6 +705,45 @@ def test_is_match(self, path: Path, expected_match: bool) -> None:
                     Path("subdir_b/build.gradle"),
                 ],
             ),
+            (
+                # mixing .gradle and .gradle.kts
+                [
+                    Path("gradle.lockfile"),
+                    Path("build.gradle"),
+                    Path("settings.gradle"),
+                    Path("buildSrc/settings.gradle.kts"),
+                    Path("subdir_a/build.gradle.kts"),
+                    Path("subdir_b/build.gradle"),
+                ],
+                [
+                    Subproject(
+                        root_dir=Path(),
+                        dependency_source=ManifestLockfileDependencySource(
+                            manifest=out.Manifest(
+                                kind=out.ManifestKind(value=out.BuildGradle()),
+                                path=out.Fpath("build.gradle"),
+                            ),
+                            lockfile=out.Lockfile(
+                                out.LockfileKind(out.GradleLockfile()),
+                                out.Fpath("gradle.lockfile"),
+                            ),
+                        ),
+                    ),
+                    Subproject(
+                        root_dir=Path("buildSrc"),
+                        dependency_source=ManifestOnlyDependencySource(
+                            manifest=out.Manifest(
+                                kind=out.ManifestKind(value=out.SettingsGradle()),
+                                path=out.Fpath("buildSrc/settings.gradle.kts"),
+                            ),
+                        ),
+                    ),
+                ],
+                [
+                    Path("subdir_a/build.gradle.kts"),
+                    Path("subdir_b/build.gradle"),
+                ],
+            ),
             (
                 # a very simple single-project build with only a build.gradle
                 [
diff --git a/src/tests/Test.ml b/src/tests/Test.ml
index 3f526a0fa43b..4f759cd71a93 100644
--- a/src/tests/Test.ml
+++ b/src/tests/Test.ml
@@ -1,3 +1,21 @@
+(* Yoann Padioleau
+ *
+ * Copyright (C) 2024 Semgrep, Inc.
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public License
+ * version 2.1 as published by the Free Software Foundation, with the
+ * special exception on linking described in file LICENSE.
+ *
+ * This library is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
+ * LICENSE for more details.
+ *)
+open Common
+
+let t = Testo.create
+
 (*****************************************************************************)
 (* Prelude *)
 (*****************************************************************************)
@@ -13,10 +31,6 @@
  * distribute them in their relevant directory (e.g., engine/Unit_engine.ml)
  *)
 
-open Common
-
-let t = Testo.create
-
 (*****************************************************************************)
 (* Helpers *)
 (*****************************************************************************)
@@ -87,7 +101,6 @@ let cleanup_before_each_test (reset : unit -> unit) (tests : Testo.t list) :
 (*****************************************************************************)
 (* All tests *)
 (*****************************************************************************)
-
 (*
    Some test suites are created from files present in file system.
    To avoid errors during module initialization when running 'dune utop'
@@ -129,33 +142,22 @@ let tests (caps : Cap.all_caps) =
       (* just expression vs expression testing for one language (Python) *)
       Unit_matcher.tests ~any_gen_of_string;
       (* TODO Unit_matcher.spatch_unittest ~xxx *)
-      (* TODO Unit_matcher_php.unittest; (* sgrep, spatch, refactoring, unparsing *) *)
+      (* TODO Unit_matcher_php.unittest; sgrep/spatch/refactoring/unparsing *)
       Unit_engine.tests ();
       Unit_jsonnet.tests (caps :> < Cap.time_limit >);
       Unit_metachecking.tests (caps :> Core_scan.caps);
-      (* OSemgrep tests *)
+      (* osemgrep unit tests *)
       Unit_LS.tests (caps :> Session.caps);
       Unit_Login.tests caps;
       Unit_Fetching.tests (caps :> < Cap.network ; Cap.tmp >);
       Unit_reporting.tests (caps :> < >);
+      Unit_ci.tests;
       Test_is_blocking_helpers.tests;
+      (* osemgrep e2e subcommand tests *)
       Test_login_subcommand.tests (caps :> < Cap.stdout ; Cap.network >);
-      Test_scan_subcommand.tests
-        (caps
-          :> < Cap.stdout
-             ; Cap.network
-             ; Cap.tmp
-             ; Cap.chdir
-             ; Cap.fork
-             ; Cap.time_limit
-             ; Cap.memory_limit >);
-      Unit_test_subcommand.tests
-        (caps
-          :> < Cap.stdout
-             ; Cap.fork
-             ; Cap.time_limit
-             ; Cap.memory_limit
-             ; Cap.tmp >);
+      Test_scan_subcommand.tests (caps :> Scan_subcommand.caps);
+      Test_ci_subcommand.tests (caps :> Ci_subcommand.caps);
+      Unit_test_subcommand.tests (caps :> Test_subcommand.caps);
       Test_show_subcommand.tests
         (caps :> < Cap.stdout ; Cap.network ; Cap.tmp >);
       Test_publish_subcommand.tests
@@ -165,15 +167,8 @@ let tests (caps : Cap.all_caps) =
       (* Networking tests disabled as they will get rate limited sometimes *)
       (* And the SSL issues they've been testing have been stable *)
       (*Unit_Networking.tests;*)
-      Test_LS_e2e.tests
-        (caps
-          :> < Cap.random
-             ; Cap.network
-             ; Cap.tmp
-             ; Cap.fork
-             ; Cap.time_limit
-             ; Cap.memory_limit >);
-      (* End OSemgrep tests *)
+      Test_LS_e2e.tests (caps :> Lsp_subcommand.caps);
+      (* End osemgrep tests *)
       Spacegrep_tests.Test.tests ();
       Aliengrep.Unit_tests.tests;
       Unit_core_json_output.tests;
diff --git a/tests/rules/sym_prop_record.js b/tests/rules/sym_prop_record.js
new file mode 100644
index 000000000000..643ebfd3df8c
--- /dev/null
+++ b/tests/rules/sym_prop_record.js
@@ -0,0 +1,11 @@
+let bar = {
+  client: "mysql",
+  connection: {
+    host: SOLA_DB_HOST,
+    port: SOLA_DB_PORT,
+    user: SOLA_DB_USER,
+    password: 'SOLA_DB_PWD',
+  },
+}
+// ruleid: test
+foo(bar)
diff --git a/tests/rules/sym_prop_record.yaml b/tests/rules/sym_prop_record.yaml
new file mode 100644
index 000000000000..fe74896ae196
--- /dev/null
+++ b/tests/rules/sym_prop_record.yaml
@@ -0,0 +1,14 @@
+rules:
+- id: test
+  message: Test
+  severity: WARNING
+  languages:
+  - js
+  - ts
+  options:
+    symbolic_propagation: true
+  patterns:
+    - pattern: |
+        foo({...,connection: {...,password:'...',...},...})
+    - pattern-not: |
+        foo({...,connection: {...,password:'',...},...})
diff --git a/tests/semgrep-rules b/tests/semgrep-rules
index 3b05904d887f..a5fbe2ef09dd 160000
--- a/tests/semgrep-rules
+++ b/tests/semgrep-rules
@@ -1,1 +1,1 @@
-Subproject commit 3b05904d887fb274c494b2e19f5f47dc1d1933c6
+Subproject commit a5fbe2ef09dd67f7aab1e6dfb432f6ccb56db77f