Repository: semgrep/semgrep (language: python)

Issue:
Merging Ruin0x11 changes for Rust



Discussion / hints:

Fix patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 2c3755e2502f..98143bdad495 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -90,7 +90,7 @@ repos:
     rev: v1.7.1 # PLEASE DO NOT CHANGE [see NOTE "Python"]
     hooks:
       - id: mypy
-        exclude: ^cli/tests/.+$|^setup.py$|^cli/src/semdep/external/packaging/.*$|^cli/src/semdep/external/parsy/.*$|^cli/scripts/.*$|^scripts/.+$|^stats/parsing-stats/.+$|^perf/.+$|^js/language_server/targets/.+$
+        exclude: ^cli/tests/.+$|^setup.py$|^cli/src/semdep/external/packaging/.*$|^cli/src/semgrep/console_scripts/.*$|^cli/src/semdep/external/parsy/.*$|^cli/scripts/.*$|^scripts/.+$|^stats/parsing-stats/.+$|^perf/.+$|^js/language_server/targets/.+$
         args: [--config, mypy.ini, --show-error-codes, --python-version, "3.11"]
         additional_dependencies: &mypy-deps
           # versions must be manually synced:
@@ -192,8 +192,8 @@ repos:
   # ----------------------------------------------------------
   # Dogfood! running semgrep in pre-commit!
   # ----------------------------------------------------------
-  - repo: https://github.com/returntocorp/semgrep
-    rev: v1.54.0
+  - repo: https://github.com/semgrep/pre-commit
+    rev: v1.93.0
     hooks:
       - id: semgrep
         name: Semgrep Jsonnet
@@ -246,8 +246,8 @@ repos:
   # Dogfooding .pre-commit-hooks.yml and setup.py
   # Use a fixed version of p/python and p/bandit to not get new findings
   # We run `semgrep ci` for our main rulesets
-  - repo: https://github.com/returntocorp/semgrep
-    rev: v1.51.0
+  - repo: https://github.com/semgrep/pre-commit
+    rev: v1.93.0
     hooks:
       - id: semgrep
         name: Semgrep Python
@@ -262,7 +262,7 @@ repos:
 
   # Run Semgrep Docker images. Only used in CI since it's slower for local developmemt.
   # To run locally use `pre-commit run --hook-stage manual semgrep-docker-develop`
-  - repo: https://github.com/returntocorp/semgrep
+  - repo: https://github.com/semgrep/pre-commit
     # Note that the 'rev:' below is the revision to use to clone the URL
     # above and to fetch its .pre-commit-hooks.yaml file. It does not always
     # mean which version of semgrep to use for the hook itself.
@@ -270,7 +270,7 @@ repos:
     # the 'language: docker_image', the 'entry:' field will then specify
     # which version to use. In the case below, we're actually running
     # returntocorp/semgrep:develop if you look at .pre-commit-hooks.yaml
-    rev: v1.51.0
+    rev: v1.93.0
     hooks:
       - id: semgrep-docker-develop
         name: Semgrep Develop Python
diff --git a/changelog.d/code-7657.fixed b/changelog.d/code-7657.fixed
new file mode 100644
index 000000000000..0e2e22ad1733
--- /dev/null
+++ b/changelog.d/code-7657.fixed
@@ -0,0 +1,9 @@
+pro: taint-mode: Semgrep should no longer confuse a `return` in a lambda with
+a `return` in its enclosing function.
+
+E.g. In the example below the return value of `foo` is NOT tainted:
+
+    function foo() {
+        bar(() => taint);
+        return ok;
+    }
diff --git a/changelog.d/local_open.fixed b/changelog.d/local_open.fixed
new file mode 100644
index 000000000000..6973c71affdf
--- /dev/null
+++ b/changelog.d/local_open.fixed
@@ -0,0 +1,3 @@
+OCaml: matching will now recognized "local open" so that a pattern like
+`Foo.bar ...` will now correctly match code such as `let open Foo in bar 1`
+or `Foo.(bar 1)` in addition to the classic `Foo.bar 1`.
diff --git a/changelog.d/saf-1598.fixed b/changelog.d/saf-1598.fixed
new file mode 100644
index 000000000000..f0973c47ec60
--- /dev/null
+++ b/changelog.d/saf-1598.fixed
@@ -0,0 +1,2 @@
+Project files lacking sufficient read permissions are now skipped gracefully
+by semgrep.
diff --git a/changelog.d/saf-1672.fixed b/changelog.d/saf-1672.fixed
new file mode 100644
index 000000000000..28796d52b445
--- /dev/null
+++ b/changelog.d/saf-1672.fixed
@@ -0,0 +1,3 @@
+Semgrep will now print stderr and additional debugging info when semgrep-core
+exits with a fatal error code but still returns a json repsonse (finishes
+scanning)
diff --git a/changelog.d/saf-1681.fixed b/changelog.d/saf-1681.fixed
new file mode 100644
index 000000000000..404883fa97b0
--- /dev/null
+++ b/changelog.d/saf-1681.fixed
@@ -0,0 +1,2 @@
+semgrep ci should parse correctly git logs to compute the set of contributors
+even if some authors have special characters in their names.
diff --git a/cli/Makefile b/cli/Makefile
index 171d4ab84742..95aa536aa9ad 100644
--- a/cli/Makefile
+++ b/cli/Makefile
@@ -20,8 +20,19 @@ build:
 
 .PHONY: check
 check:
+	# TODO: also run 'typecheck' after making it work in CI (without git
+	# or pre-commit)
+	# $(MAKE) typecheck
 	$(MAKE) check-markers
 
+# TODO: is there a way to run the same commands as the pre-commit hook
+# without code duplication and without depending on pre-commit?
+# Various issues with external libraries arise when trying to
+# run 'pipenv run mypy' here directly.
+.PHONY: typecheck
+typecheck:
+	pre-commit run mypy -a
+
 # Check that all the test_* functions are categorized by duration.
 .PHONY: check-markers
 check-markers:
@@ -59,11 +70,19 @@ PYTEST ?= \
 #
 # coupling: this must run the same tests as 'make test-dev'.
 #
-.PHONY: test
-test:
+.PHONY: ci-test
+ci-test:
 	# To fail faster, consider running 'make test-dev' instead.
 	$(MAKE) check
-	$(PYTEST) tests/default
+	$(PYTEST) -m 'not pysemfail' tests/default
+
+# While 'make typecheck' doesn't work in CI, use 'make ci-test'
+.PHONY: test
+test:
+	# If 'make typecheck' fails, consider calling 'make ci-test' instead
+	# of 'make test'.
+	$(MAKE) typecheck
+	$(MAKE) ci-test
 
 # Run the same tests as 'make test' but try to fail faster.
 #
@@ -111,7 +130,19 @@ test-dev:
 # We exclude the few tests that don't invoke the semgrep command directly.
 .PHONY: e2e
 e2e:
-	$(PYTEST) tests/default/e2e
+	$(PYTEST) -m 'not pysemfail' tests/default/e2e
+
+# Run the end-to-end tests using osemgrep instead of pysemgrep.
+# See tests/semgrep_runner.py for the use of the environment variables below.
+# To run individual tests, export PYTEST_USE_OSEMGREP=true
+# and run 'pytest tests/default/e2e/test_output.py' in a shell for example.
+#TODO: right now we just run the e2e-only because it's our main goal for Q4
+#
+# coupling: must match the 'e2e' target above except for the pysemfail/osemfail
+# markers
+.PHONY: osemgrep-e2e
+osemgrep-e2e:
+	PYTEST_USE_OSEMGREP=true $(PYTEST) -m 'not osemfail' tests/default/e2e
 
 .PHONY: e2e-other
 e2e-other:
@@ -132,15 +163,6 @@ qa:
 # Osemgrep tests
 ###############################################################################
 
-# Run the end-to-end tests using osemgrep instead of pysemgrep.
-# See tests/semgrep_runner.py for the use of the environment variables below.
-# To run individual tests, export PYTEST_USE_OSEMGREP=true
-# and run 'pytest tests/default/e2e/test_output.py' in a shell for example.
-#TODO: right now we just run the e2e-only because it's our main goal for Q4
-.PHONY: osemgrep-e2e
-osemgrep-e2e:
-	PYTEST_USE_OSEMGREP=true $(MAKE) e2e
-
 # Run all the known passing-with-osemgrep tests (not tagged with osemfail)
 # coupling: this is run in CI in .github/workflows/tests.yml
 osempass:
diff --git a/cli/pyproject.toml b/cli/pyproject.toml
index c23a7d345333..68b82ef3064e 100644
--- a/cli/pyproject.toml
+++ b/cli/pyproject.toml
@@ -27,6 +27,7 @@ markers = [
     "todo: the test is not implemented or needs work",
     "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",
 ]
 
 [tool.tox]
diff --git a/cli/setup.py b/cli/setup.py
index 4896075ed280..f276977e010c 100644
--- a/cli/setup.py
+++ b/cli/setup.py
@@ -148,7 +148,13 @@ def find_executable(env_name, exec_name):
     long_description=long_description,
     long_description_content_type="text/markdown",
     url="https://github.com/returntocorp/semgrep",
-    scripts=["bin/semgrep", "bin/pysemgrep"],
+    # creates a .exe wrapper on windows
+    entry_points={
+        "console_scripts": [
+            "semgrep = semgrep.console_scripts.entrypoint:main",
+            "pysemgrep = semgrep.console_scripts.pysemgrep:main",
+        ]
+    },
     packages=setuptools.find_packages(where="src"),
     package_dir={"": "src"},
     package_data={"semgrep": [os.path.join("bin", "*")]},
diff --git a/cli/src/semdep/subproject_matchers.py b/cli/src/semdep/subproject_matchers.py
index 6ca973ef50cc..cf96cc15a623 100644
--- a/cli/src/semdep/subproject_matchers.py
+++ b/cli/src/semdep/subproject_matchers.py
@@ -1,6 +1,7 @@
 from abc import ABC
 from abc import abstractmethod
 from dataclasses import dataclass
+from dataclasses import field
 from fnmatch import fnmatch
 from pathlib import Path
 from typing import Dict
@@ -60,6 +61,7 @@ class LockfileManifestMatcher(SubprojectMatcher):
     Child classes must implement _is_manifest_match, _is_lockfile_match, _lockfile_to_manifest, and _get_subproject_root
     """
 
+    manifest_kind: out.ManifestKind
     package_manager_type: PackageManagerType
 
     @abstractmethod
@@ -147,8 +149,8 @@ def make_subprojects(
                     (matching_manifest_path, lockfile_path)
                 )
             dep_source = LockfileDependencySource(
+                manifest=(self.manifest_kind, matching_manifest_path),
                 package_manager_type=self.package_manager_type,
-                manifest_path=matching_manifest_path,
                 lockfile_path=lockfile_path,
             )
             subprojects.append(
@@ -324,6 +326,9 @@ class PipRequirementsMatcher(SubprojectMatcher):
     manifest_file_extension: str
 
     default_manifest_file_base: str  # without extension
+    manifest_kind: out.ManifestKind = field(
+        default_factory=lambda: out.ManifestKind(value=out.RequirementsIn())
+    )
 
     def _is_requirements_match(self, path: Path) -> bool:
         for ext in self.requirements_file_extensions:
@@ -446,9 +451,9 @@ def make_subprojects(
                     paired_manifests.add(matching_manifest_path)
                 lockfile_sources.append(
                     LockfileDependencySource(
+                        manifest=(self.manifest_kind, matching_manifest_path),
                         package_manager_type=PackageManagerType.PIP,
                         lockfile_path=req_path,
-                        manifest_path=matching_manifest_path,
                     )
                 )
 
@@ -487,45 +492,53 @@ def make_subprojects(
         lockfile_name="package-lock.json",
         manifest_name="package.json",
         package_manager_type=PackageManagerType.NPM,
+        manifest_kind=out.ManifestKind(out.PackageJson()),
     ),
     ExactLockfileManifestMatcher(
         lockfile_name="yarn.lock",
         manifest_name="package.json",
         package_manager_type=PackageManagerType.YARN,
+        manifest_kind=out.ManifestKind(out.PackageJson()),
     ),
     ExactLockfileManifestMatcher(
         lockfile_name="pnpm-lock.yaml",
         manifest_name="package.json",
         package_manager_type=PackageManagerType.PNPM,
+        manifest_kind=out.ManifestKind(out.PackageJson()),
     ),
     # Gem
     ExactLockfileManifestMatcher(
         lockfile_name="Gemfile.lock",
         manifest_name="Gemfile",
         package_manager_type=PackageManagerType.RUBY_GEM,
+        manifest_kind=out.ManifestKind(out.Gemfile()),
     ),
     # Go modules
     ExactLockfileManifestMatcher(
         lockfile_name="go.mod",
         manifest_name="go.mod",
         package_manager_type=PackageManagerType.GO_MOD,
+        manifest_kind=out.ManifestKind(out.GoMod_()),
     ),
     # Cargo
     ExactLockfileManifestMatcher(
         lockfile_name="Cargo.lock",
         manifest_name="Cargo.toml",
         package_manager_type=PackageManagerType.CARGO,
+        manifest_kind=out.ManifestKind(out.CargoToml()),
     ),
     # Maven
     ExactLockfileManifestMatcher(
         lockfile_name="maven_dep_tree.txt",
         manifest_name="pom.xml",
         package_manager_type=PackageManagerType.MAVEN,
+        manifest_kind=out.ManifestKind(out.PomXml()),
     ),
     ExactLockfileManifestMatcher(
         lockfile_name="gradle.lockfile",
         manifest_name="build.gradle",
         package_manager_type=PackageManagerType.GRADLE,
+        manifest_kind=out.ManifestKind(out.BuildGradle()),
     ),
     ExactManifestOnlyMatcher(
         manifest_kind=out.ManifestKind(out.PomXml()),
@@ -540,42 +553,49 @@ def make_subprojects(
         lockfile_name="composer.lock",
         manifest_name="composer.json",
         package_manager_type=PackageManagerType.COMPOSER,
+        manifest_kind=out.ManifestKind(out.ComposerJson()),
     ),
     # Nuget
     ExactLockfileManifestMatcher(
         lockfile_name="packages.lock.json",
         manifest_name="nuget.manifest.json",
         package_manager_type=PackageManagerType.NUGET,
+        manifest_kind=out.ManifestKind(out.NugetManifestJson()),
     ),
     # Pub
     ExactLockfileManifestMatcher(
         lockfile_name="pubspec.lock",
         manifest_name="pubspec.yaml",
         package_manager_type=PackageManagerType.DART_PUB,
+        manifest_kind=out.ManifestKind(out.PubspecYaml()),
     ),
     # Swift PM
     ExactLockfileManifestMatcher(
         lockfile_name="Package.resolved",
         manifest_name="Package.swift",
         package_manager_type=PackageManagerType.SWIFT_PM,
+        manifest_kind=out.ManifestKind(out.PackageSwift_()),
     ),
     # Hex
     ExactLockfileManifestMatcher(
         lockfile_name="mix.lock",
         manifest_name="mix.exs",
         package_manager_type=PackageManagerType.ELIXIR_HEX,
+        manifest_kind=out.ManifestKind(out.MixExs()),
     ),
     # Pipenv
     ExactLockfileManifestMatcher(
         lockfile_name="Pipfile.lock",
         manifest_name="Pipfile",
         package_manager_type=PackageManagerType.PIPENV,
+        manifest_kind=out.ManifestKind(out.Pipfile_()),
     ),
     # Poetry
     ExactLockfileManifestMatcher(
         lockfile_name="poetry.lock",
         manifest_name="pyproject.toml",
         package_manager_type=PackageManagerType.POETRY,
+        manifest_kind=out.ManifestKind(out.PyprojectToml_()),
     ),
 ]
 
diff --git a/cli/src/semgrep/app/scans.py b/cli/src/semgrep/app/scans.py
index 141a0b18f19e..6e9b39f4d813 100644
--- a/cli/src/semgrep/app/scans.py
+++ b/cli/src/semgrep/app/scans.py
@@ -81,6 +81,15 @@ def scan_id(self) -> Optional[int]:
             return self.scan_response.info.id
         return None
 
+    @property
+    def always_suppress_errors(self) -> bool:
+        """
+        Separate property for easy of mocking in test
+        """
+        if self.scan_response:
+            return self.scan_response.engine_params.always_suppress_errors
+        return False
+
     @property
     def deployment_id(self) -> Optional[int]:
         """
@@ -117,6 +126,19 @@ def deepsemgrep(self) -> bool:
             return self.scan_response.engine_params.deepsemgrep
         return False
 
+    @property
+    def prioritize_dependency_graph_generation(self) -> bool:
+        """
+        Separate property for easy of mocking in test
+
+        If path to transitivity has been enabled for a deployment, default dependency resolution
+        will be overridden and the CLI will attempt to generate a dependency graph for ecosystems
+        which graph generation has been implemented for.
+        """
+        if self.scan_response:
+            return self.scan_response.engine_params.path_to_transitivity
+        return False
+
     @property
     def generic_slow_rollout(self) -> bool:
         """
diff --git a/cli/src/semgrep/commands/ci.py b/cli/src/semgrep/commands/ci.py
index f47f2741d8e0..84e360e1c775 100644
--- a/cli/src/semgrep/commands/ci.py
+++ b/cli/src/semgrep/commands/ci.py
@@ -187,6 +187,12 @@ def fix_head_if_github_action(metadata: GitMeta) -> None:
     default=0,
     hidden=True,
 )
+@click.option(
+    "--x-dump-rule-partitions-dir",
+    "dump_rule_partitions_dir",
+    type=click.Path(allow_dash=True, path_type=Path),
+    hidden=True,
+)
 @click.option(
     "--x-partial-config",
     "partial_config",
@@ -262,6 +268,7 @@ def ci(
     enable_experimental_requirements: bool,
     allow_dynamic_dependency_resolution: bool,
     dump_n_rule_partitions: Optional[int],
+    dump_rule_partitions_dir: Optional[Path],
     partial_config: Optional[Path],
     partial_output: Optional[Path],
 ) -> None:
@@ -304,6 +311,14 @@ def ci(
             )
             sys.exit(FATAL_EXIT_CODE)
 
+        if (dump_n_rule_partitions and not dump_rule_partitions_dir) or (
+            not dump_n_rule_partitions and dump_rule_partitions_dir
+        ):
+            logger.info(
+                "Both or none of --x-dump-rule-partitions and --x-dump-rule-partitions-dir must be specified."
+            )
+            sys.exit(FATAL_EXIT_CODE)
+
         if (partial_config and not partial_output) or (
             not partial_config and partial_output
         ):
@@ -341,6 +356,10 @@ def ci(
         else:  # impossible state… until we break the code above
             raise RuntimeError("The token and/or config are misconfigured")
 
+        # For account admins to force suppressing errors. This overrides the CLI flag!
+        if scan_handler and scan_handler.always_suppress_errors:
+            state.error_handler.configure(suppress_errors=True)
+
         metadata = generate_meta_from_environment(baseline_commit, subdir)
 
         console.print(Title("Debugging Info"))
@@ -599,6 +618,10 @@ def ci(
             "enable_experimental_requirements": enable_experimental_requirements,
             "allow_dynamic_dependency_resolution": allow_dynamic_dependency_resolution,
             "dump_n_rule_partitions": dump_n_rule_partitions,
+            "dump_rule_partitions_dir": dump_rule_partitions_dir,
+            "prioritize_dependency_graph_generation": scan_handler.prioritize_dependency_graph_generation
+            if scan_handler
+            else False,
         }
 
         try:
diff --git a/cli/src/semgrep/commands/scan.py b/cli/src/semgrep/commands/scan.py
index 7ca564da8a74..a55de1555e06 100644
--- a/cli/src/semgrep/commands/scan.py
+++ b/cli/src/semgrep/commands/scan.py
@@ -478,6 +478,11 @@ def scan_options(func: Callable) -> Callable:
     is_flag=True,
     default=False,
 )
+@optgroup.option(
+    "--x-ls-long",
+    is_flag=True,
+    default=False,
+)
 @optgroup.group("Test and debug options")
 @optgroup.option("--test", is_flag=True, default=False)
 @optgroup.option(
@@ -577,6 +582,7 @@ def scan(
     verbose: bool,
     version: bool,
     x_ls: bool,
+    x_ls_long: bool,
     path_sensitive: bool,
     enable_experimental_requirements: bool,
     allow_dynamic_dependency_resolution: bool,
@@ -852,6 +858,7 @@ def scan(
                         optimizations=optimizations,
                         baseline_commit=baseline_commit,
                         x_ls=x_ls,
+                        x_ls_long=x_ls_long,
                         path_sensitive=path_sensitive,
                         capture_core_stderr=capture_core_stderr,
                         enable_experimental_requirements=enable_experimental_requirements,
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1.3R/results.json b/cli/src/semgrep/console_scripts/__init__.py
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1.3R/results.json
rename to cli/src/semgrep/console_scripts/__init__.py
diff --git a/cli/bin/semgrep b/cli/src/semgrep/console_scripts/entrypoint.py
similarity index 80%
rename from cli/bin/semgrep
rename to cli/src/semgrep/console_scripts/entrypoint.py
index 15d6cd491aa6..49a7899059dd 100755
--- a/cli/bin/semgrep
+++ b/cli/src/semgrep/console_scripts/entrypoint.py
@@ -1,5 +1,4 @@
 #!/usr/bin/env python3
-
 # This file is the Semgrep CLI entry point of the Semgrep pip package,
 # the Semgrep HomeBrew package, and the Semgrep Docker container.
 #
@@ -28,16 +27,17 @@
 #
 # NOTE: if you modify this file, you will need to `pipenv install --dev`
 # if you want to test the change under `pipenv shell`.
-
+import importlib.resources
 import os
 import platform
-import sys
-import importlib.resources
 import shutil
-#alt: you can also add '-W ignore::DeprecationWarning' after the python3 above,
+import sys
+import warnings
+
+# alt: you can also add '-W ignore::DeprecationWarning' after the python3 above,
 # but setuptools and pip adjust this line when installing semgrep so we need
 # to do this instead.
-import warnings
+
 warnings.filterwarnings("ignore", category=DeprecationWarning)
 
 # Add the directory containing this script in the PATH, so the pysemgrep
@@ -47,7 +47,9 @@
 # that calling pysemgrep from osemgrep would be difficult because
 # it would not be in the PATH (we would need to pass its path to osemgrep,
 # which seems more complicated).
+# nosem: no-env-vars-on-top-level
 PATH = os.environ.get("PATH", "")
+# nosem: no-env-vars-on-top-level
 os.environ["PATH"] = PATH + os.pathsep + os.path.dirname(os.path.abspath(__file__))
 
 IS_WINDOWS = platform.system() == "Windows"
@@ -56,9 +58,10 @@
 class CoreNotFound(Exception):
     def __init__(self, value):
         self.value = value
- 
+
     def __str__(self):
-        return(self.value)
+        return self.value
+
 
 # Similar to cli/src/semgrep/engine.py check_is_correct_pro_version
 def is_correct_pro_version(core_path):
@@ -70,15 +73,17 @@ def is_correct_pro_version(core_path):
     This can be verified with `time python -c 'import semgrep; print(semgrep.__VERSION__)'`
     """
     from semgrep import __VERSION__
+
     # Duplicate of cli/src/semgrep/semgrep_core.py pro_version_stamp_path
     stamp_path = core_path.parent / "pro-installed-by.txt"
     if stamp_path.is_file():
-        with stamp_path.open('r') as f:
+        with stamp_path.open("r") as f:
             version_at_install = f.readline().strip()
             return version_at_install == __VERSION__
     else:
         return False
 
+
 # similar to cli/src/semgrep/semgrep_core.py compute_executable_path()
 def find_semgrep_core_path(pro=False, extra_message=""):
     if pro:
@@ -96,9 +101,11 @@ def find_semgrep_core_path(pro=False, extra_message=""):
         with importlib.resources.path("semgrep.bin", core) as path:
             if path.is_file():
                 if pro and not is_correct_pro_version(path):
-                    raise CoreNotFound(f"The installed version of {core} is out of date.{extra_message}")
+                    raise CoreNotFound(
+                        f"The installed version of {core} is out of date.{extra_message}"
+                    )
                 return str(path)
-    except (FileNotFoundError, ModuleNotFoundError) as e:
+    except (FileNotFoundError, ModuleNotFoundError):
         pass
 
     # Second, try in PATH. In certain context such as Homebrew
@@ -110,20 +117,25 @@ def find_semgrep_core_path(pro=False, extra_message=""):
     path = shutil.which(core)
     if path is not None:
         return path
- 
-    raise CoreNotFound(f"Failed to find {core} in PATH or in the semgrep package.{extra_message}")
 
-#TODO: we should just do 'execvp("pysemgrep", sys.argv)'
+    raise CoreNotFound(
+        f"Failed to find {core} in PATH or in the semgrep package.{extra_message}"
+    )
+
+
+# TODO: we should just do 'execvp("pysemgrep", sys.argv)'
 # but this causes some regressions with --test (see PA-2963)
 # and autocomplete (see #8359)
-#TODO: we should get rid of autocomplete anyway (it's a Python Click
+# TODO: we should get rid of autocomplete anyway (it's a Python Click
 # thing not supported by osemgrep anyway),
-#TODO: we should fix --test instead.
+# TODO: we should fix --test instead.
 # The past investigation of Austin is available in #8360 PR comments
 def exec_pysemgrep():
     import semgrep.main
+
     sys.exit(semgrep.main.main())
 
+
 # We could have moved the code below in a separate 'osemgrep' file, like
 # for 'pysemgrep', but we don't want users to be exposed to another command,
 # so it is better to hide it.
@@ -134,19 +146,23 @@ def exec_pysemgrep():
 # they'll get the old behavior.
 def exec_osemgrep():
     argv = sys.argv
-    if ("--pro" in argv or
-        "--beta-testing-secrets-enabled" in argv):
+    if "--pro" in argv or "--beta-testing-secrets-enabled" in argv:
         try:
-            path = find_semgrep_core_path(pro=True,
-                                      extra_message="\nYou may need to run `semgrep install-semgrep-pro`"    )
+            path = find_semgrep_core_path(
+                pro=True,
+                extra_message="\nYou may need to run `semgrep install-semgrep-pro`",
+            )
         except CoreNotFound as e:
             print(str(e), file=sys.stderr)
             if sys.argv[1] == "ci":
                 # CI users usually want things to just work. In particular, if they
                 # are running `semgrep ci --pro` they don't want to have to add an
                 # extra step to install-semgrep-pro. This wrapper doesn't have a way
-                # to install semgrep-pro, however, so have them run legacy `semgrep`. 
-                print("Since `semgrep ci` was run, defaulting to legacy semgrep", file=sys.stderr)
+                # to install semgrep-pro, however, so have them run legacy `semgrep`.
+                print(
+                    "Since `semgrep ci` was run, defaulting to legacy semgrep",
+                    file=sys.stderr,
+                )
                 exec_pysemgrep()
             else:
                 sys.exit(2)
@@ -164,23 +180,29 @@ def exec_osemgrep():
         # If you call semgrep-core as osemgrep, then we get
         # osemgrep behavior, see src/main/Main.ml
         sys.argv[0] = "osemgrep"
+    # nosem: dangerous-os-exec-tainted-env-args
     os.execvp(str(path), sys.argv)
 
+
 # Needed for similar reasons as in pysemgrep, but only for the legacy
 # flag to work
-if __name__ == "__main__":
+def main():
     # escape hatch for users to pysemgrep in case of problems (they
     # can also call directly 'pysemgrep').
     if "--legacy" in sys.argv:
-       sys.argv.remove("--legacy")
-       exec_pysemgrep()
+        sys.argv.remove("--legacy")
+        exec_pysemgrep()
     elif "--experimental" in sys.argv:
-       exec_osemgrep()
+        exec_osemgrep()
     else:
-       # we now default to osemgrep! but this will usually exec
-       # back to pysemgrep for most commands (for now)
-       # We activate the new CLI UX only when semgrep is invoked directly
-       # (and legacy is not specified)
-       # and osemgrep needs to fallback on pysemgrep
-       os.environ["SEMGREP_NEW_CLI_UX"] = f"{int(sys.stdout.isatty())}"
-       exec_osemgrep()
+        # we now default to osemgrep! but this will usually exec
+        # back to pysemgrep for most commands (for now)
+        # We activate the new CLI UX only when semgrep is invoked directly
+        # (and legacy is not specified)
+        # and osemgrep needs to fallback on pysemgrep
+        os.environ["SEMGREP_NEW_CLI_UX"] = f"{int(sys.stdout.isatty())}"
+        exec_osemgrep()
+
+
+if __name__ == "__main__":
+    main()
diff --git a/cli/bin/pysemgrep b/cli/src/semgrep/console_scripts/pysemgrep.py
similarity index 96%
rename from cli/bin/pysemgrep
rename to cli/src/semgrep/console_scripts/pysemgrep.py
index 373132739ddb..8d5be4a748df 100755
--- a/cli/bin/pysemgrep
+++ b/cli/src/semgrep/console_scripts/pysemgrep.py
@@ -1,5 +1,4 @@
 #!/usr/bin/env python3
-
 # This script will be installed by pip somewhere in PATH (e.g., /opt/local/bin/)
 # because it's mentioned in ../setup.py with 'scripts=[..., "bin/pysemgrep"]'.
 # Thus, osemgrep can easily fallback to pysemgrep (without any further
@@ -11,9 +10,9 @@
 # then knows where to find the Semgrep Python source files referenced below
 # with semgrep.main.main
 # (e.g., in /opt/local/lib/site-packages/python3.11/semgrep/)
-
 import os
 import sys
+
 # This file is not part of the Python 'semgrep' package; it's a script.
 # It's an escape hatch for users to pysemgrep in case of problems.
 
@@ -45,8 +44,13 @@
 # See safe importing of main modules here:
 # https://docs.python.org/3/library/multiprocessing.html#the-spawn-and-forkserver-start-methods
 # It doesn't explain in enough detail to give a real answer, hence the guess above.
-if __name__ == "__main__":
+def main():
     # Only enable new CLI UX when pysemgrep is invoked directly and with interactive terminal
-    os.environ["SEMGREP_NEW_CLI_UX"] =  f"{int(sys.stdout.isatty())}"
+    os.environ["SEMGREP_NEW_CLI_UX"] = f"{int(sys.stdout.isatty())}"
     import semgrep.main
+
     sys.exit(semgrep.main.main())
+
+
+if __name__ == "__main__":
+    main()
diff --git a/cli/src/semgrep/core_output.py b/cli/src/semgrep/core_output.py
index 6b5aeed9bbe5..e134e33c6079 100644
--- a/cli/src/semgrep/core_output.py
+++ b/cli/src/semgrep/core_output.py
@@ -41,6 +41,7 @@ def core_error_to_semgrep_error(err: out.CoreError) -> SemgrepCoreError:
     level = err.severity
     spans: Optional[List[out.ErrorSpan]] = None
     if isinstance(err.error_type.value, out.PatternParseError):
+        assert err.location
         yaml_path = err.error_type.value.value[::-1]
         error_span = _core_location_to_error_span(err.location)
         config_start = out.Position(line=0, col=1, offset=-1)
diff --git a/cli/src/semgrep/core_runner.py b/cli/src/semgrep/core_runner.py
index 59956782d61b..1234c078ac1f 100644
--- a/cli/src/semgrep/core_runner.py
+++ b/cli/src/semgrep/core_runner.py
@@ -143,16 +143,20 @@ def dedup_errors(errors: List[SemgrepCoreError]) -> List[SemgrepCoreError]:
     return list({uniq_error_id(e): e for e in errors}.values())
 
 
-def uniq_error_id(
-    error: SemgrepCoreError,
-) -> Tuple[int, Path, out.Position, out.Position, str]:
-    return (
-        error.code,
-        Path(error.core.location.path.value),
-        error.core.location.start,
-        error.core.location.end,
-        error.core.message,
-    )
+def uniq_error_id(error: SemgrepCoreError) -> Any:
+    if error.core.location:
+        return (
+            error.code,
+            Path(error.core.location.path.value),
+            error.core.location.start,
+            error.core.location.end,
+            error.core.message,
+        )
+    else:
+        return (
+            error.code,
+            error.core.message,
+        )
 
 
 @tracing.trace()
@@ -545,18 +549,8 @@ def _extract_core_output(
                 "<semgrep-core stderr not captured, should be printed above>\n"
             )
 
-        # By default, we print semgrep-core's error output, which includes
-        # semgrep-core's logging if it was requested via --debug.
-        #
-        # If semgrep-core prints anything on stderr when running with default
-        # flags, it's a bug that should be fixed in semgrep-core.
-        #
-        logger.debug(
-            f"--- semgrep-core stderr ---\n"
-            f"{core_stderr}"
-            f"--- end semgrep-core stderr ---"
-        )
-
+        # All paths in this block should call self._fail() to raise a
+        # SemgrepError, as something is wrong if semgrep-core's exit code is non zero!!
         if returncode != 0:
             output_json = self._parse_core_output(
                 shell_command, core_stdout, core_stderr, returncode
@@ -565,15 +559,24 @@ def _extract_core_output(
             if "errors" in output_json:
                 parsed_output = out.CoreOutput.from_json(output_json)
                 errors = parsed_output.errors
+                fail_msg = (
+                    "non-zero exit status with one or more errors in json response"
+                )
+                semgrep_errors = None
                 if len(errors) < 1:
-                    self._fail(
-                        "non-zero exit status errors array is empty in json response",
-                        shell_command,
-                        returncode,
-                        core_stdout,
-                        core_stderr,
+                    fail_msg = (
+                        "non-zero exit status errors array is empty in json response"
                     )
-                raise core_error_to_semgrep_error(errors[0])
+                else:
+                    semgrep_errors = [core_error_to_semgrep_error(e) for e in errors]
+                self._fail(
+                    fail_msg,
+                    shell_command,
+                    returncode,
+                    core_stdout,
+                    core_stderr,
+                    semgrep_errors,
+                )
             else:
                 self._fail(
                     'non-zero exit status with missing "errors" field in json response',
@@ -583,6 +586,22 @@ def _extract_core_output(
                     core_stderr,
                 )
 
+        # By default, we print semgrep-core's error output, which includes
+        # semgrep-core's logging if it was requested via --debug.
+        #
+        # If semgrep-core prints anything on stderr when running with default
+        # flags, it's a bug that should be fixed in semgrep-core.
+        #
+        # NOTE: We print the stderr here, AFTER the above if block, as all
+        # branches of the above if block result in self._fail. self._fail always
+        # prints the stderr and never returns, so by doing this we avoid
+        # printing stderr twice
+        logger.debug(
+            f"--- semgrep-core stderr ---\n"
+            f"{core_stderr}"
+            f"--- end semgrep-core stderr ---"
+        )
+
         # else:
         output_json = self._parse_core_output(
             shell_command, core_stdout, core_stderr, returncode
@@ -650,14 +669,31 @@ def _fail(
         returncode: int,
         semgrep_output: str,
         semgrep_error_output: str,
+        semgrep_errors: Optional[List[SemgrepCoreError]] = None,
     ) -> None:
+        """Raise a Semgrep Error that indicates semgrep-core ran into an error and exited
+        abnormally. It will print the reason, shell_command, return code, and stdout/stderr of semgrep-core
+
+        By default we assume that no json response was produced/was produced
+        incorrectly, and therefore could not extract any core errors, and this
+        message will indicate so
+
+        If core errors are passed, i.e. we were able to extract core errors from
+        a response json, this method will print those out also
+
+        """
+        expected_json_msg = "unexpected non-json output while invoking semgrep-core:"
+        if semgrep_errors:
+            errors_str = "\n".join([f"   {error}" for error in semgrep_errors])
+            expected_json_msg = f"unexpected errors in json output after invoking semgrep-core:\n{errors_str}"
+
         # Once we require python >= 3.8, switch to using shlex.join instead
         # for proper quoting of the command line.
         details = with_color(
             Colors.white,
             f"semgrep-core exit code: {returncode}\n"
             f"semgrep-core command: {shell_command}\n"
-            f"unexpected non-json output while invoking semgrep-core:\n"
+            f"{expected_json_msg}\n"
             "--- semgrep-core stdout ---\n"
             f"{semgrep_output}"
             "--- end semgrep-core stdout ---\n"
@@ -1008,7 +1044,7 @@ def _run_rules_direct_to_semgrep_core_helper(
             parsed_errors = [core_error_to_semgrep_error(e) for e in core_output.errors]
             for err in core_output.errors:
                 if isinstance(err.error_type.value, out.Timeout):
-                    assert err.location.path is not None
+                    assert err.location and err.location.path is not None
 
                     file_timeouts[Path(err.location.path.value)] += 1
                     if (
@@ -1098,6 +1134,7 @@ def _run_rules_direct_to_semgrep_core(
 Exception raised: `{e}`
                     """
                 )
+                # replace the sys.exit below with `raise e` to help debug
                 sys.exit(2)
             raise e
 
diff --git a/cli/src/semgrep/error.py b/cli/src/semgrep/error.py
index 6c0e9623ae94..c688ffb3955c 100644
--- a/cli/src/semgrep/error.py
+++ b/cli/src/semgrep/error.py
@@ -137,11 +137,13 @@ def adjust_CliError(self, base: out.CliError) -> out.CliError:
         if self.core.rule_id:
             base = dataclasses.replace(base, rule_id=self.core.rule_id)
 
-        # For rule errors path is a temp file so for now will just be confusing to add
-        if not isinstance(
-            self.core.error_type.value, out.RuleParseError
-        ) and not isinstance(self.core.error_type.value, out.PatternParseError):
-            base = dataclasses.replace(base, path=self.core.location.path)
+        if self.core.location:
+            # For rule errors path is a temp file so for now will just be
+            # confusing to add
+            if not isinstance(
+                self.core.error_type.value, out.RuleParseError
+            ) and not isinstance(self.core.error_type.value, out.PatternParseError):
+                base = dataclasses.replace(base, path=self.core.location.path)
 
         if self.spans:
             base = dataclasses.replace(base, spans=self.spans)
@@ -190,11 +192,15 @@ def _error_message(self) -> str:
                 error_context = self.core.rule_id.value
             elif isinstance(self.core.error_type.value, out.MissingPlugin):
                 error_context = f"for rule {self.core.rule_id.value}"
-            else:
+            elif self.core.location:
                 # This message is suitable only if the error is in a target file:
                 error_context = f"when running {self.core.rule_id.value} on {self.core.location.path.value}"
-        else:
+            else:
+                error_context = f"when running {self.core.rule_id.value}"
+        elif self.core.location:
             error_context = f"at line {self.core.location.path.value}:{self.core.location.start.line}"
+        else:
+            error_context = ""
 
         return f"{error_type_string(self.core.error_type)} {error_context}:\n {self.core.message}"
 
@@ -215,19 +221,31 @@ def __str__(self) -> str:
     # TODO: I didn't manage to get out.Error to be hashable because it contains lists or
     # objects (e.g., Error_) which are not hashable
     def __hash__(self) -> int:
-        return hash(
-            (
-                self.code,
-                self.level,
-                self.core.rule_id,
-                self.core.error_type.kind,
-                self.core.location.path.value,
-                self.core.location.start,
-                self.core.location.end,
-                self.core.message,
-                self.core.details,
+        if self.core.location:
+            return hash(
+                (
+                    self.code,
+                    self.level,
+                    self.core.rule_id,
+                    self.core.error_type.kind,
+                    self.core.location.path.value,
+                    self.core.location.start,
+                    self.core.location.end,
+                    self.core.message,
+                    self.core.details,
+                )
+            )
+        else:
+            return hash(
+                (
+                    self.code,
+                    self.level,
+                    self.core.rule_id,
+                    self.core.error_type.kind,
+                    self.core.message,
+                    self.core.details,
+                )
             )
-        )
 
 
 @attr.s(auto_attribs=True, frozen=True)
diff --git a/cli/src/semgrep/formatter/text.py b/cli/src/semgrep/formatter/text.py
index a995cf0e56df..a13e3ed5d983 100644
--- a/cli/src/semgrep/formatter/text.py
+++ b/cli/src/semgrep/formatter/text.py
@@ -493,7 +493,10 @@ def print_time_summary(
         err for err in error_output if isinstance(err, SemgrepCoreError)
     ]
     errors = {
-        (err.core.location.path.value, err.core.error_type.kind)
+        (
+            err.core.location.path.value if err.core.location else "",
+            err.core.error_type.kind,
+        )
         for err in semgrep_core_errors
     }
 
diff --git a/cli/src/semgrep/join_rule.py b/cli/src/semgrep/join_rule.py
index 085e7b734af7..a98d4be66e89 100644
--- a/cli/src/semgrep/join_rule.py
+++ b/cli/src/semgrep/join_rule.py
@@ -402,6 +402,7 @@ def run_join_rule(
     join_rule: Dict[str, Any],
     targets: List[Path],
     allow_dynamic_dependency_resolution: bool = False,
+    prioritize_dependency_graph_generation: bool = False,
 ) -> Tuple[List[RuleMatch], List[SemgrepError]]:
     """
     Run a 'join' mode rule.
@@ -493,6 +494,7 @@ def run_join_rule(
             no_rewrite_rule_ids=True,
             optimizations="all",
             allow_dynamic_dependency_resolution=allow_dynamic_dependency_resolution,
+            prioritize_dependency_graph_generation=prioritize_dependency_graph_generation,
         )
 
     assert isinstance(output, dict)  # placate mypy
diff --git a/cli/src/semgrep/main.py b/cli/src/semgrep/main.py
index b711a353cee8..c2090c2cc6a2 100644
--- a/cli/src/semgrep/main.py
+++ b/cli/src/semgrep/main.py
@@ -29,7 +29,7 @@ def conditional_welcome() -> None:
     if args.intersection(exclusionary_pysemgrep_commands):
         return
     # Exclude alternate modes for the scan subcommand,
-    # e.g. validation, version check, test, patern mode.
+    # e.g. validation, version check, test, pattern mode.
     exclusionary_options = {
         # Validation
         "--validate",
diff --git a/cli/src/semgrep/output.py b/cli/src/semgrep/output.py
index b913086509d4..ba51ef31a9be 100644
--- a/cli/src/semgrep/output.py
+++ b/cli/src/semgrep/output.py
@@ -270,6 +270,7 @@ def handle_semgrep_errors(self, errors: Sequence[SemgrepError]) -> None:
                 and err.is_timeout()
                 and err not in self.error_set
             ):
+                assert err.core.location
                 self.semgrep_structured_errors.append(err)
                 self.error_set.add(err)
 
@@ -360,6 +361,7 @@ def _final_raise(self, ex: Optional[Exception]) -> None:
         else:
             raise ex
 
+    # group semgrep core errors by path
     @staticmethod
     def _make_failed_to_analyze(
         semgrep_core_errors: Sequence[SemgrepCoreError],
@@ -368,6 +370,8 @@ def update_failed_to_analyze(
             memo: Mapping[Path, Tuple[Optional[int], List[out.RuleId]]],
             err: SemgrepCoreError,
         ) -> Mapping[Path, Tuple[Optional[int], List[out.RuleId]]]:
+            if not err.core.location:
+                return memo
             path = Path(err.core.location.path.value)
             so_far = memo.get(path, (0, []))
             if err.spans is None or so_far[0] is None:
diff --git a/cli/src/semgrep/parsing_data.py b/cli/src/semgrep/parsing_data.py
index 55c6ff78c3dd..b8e106dda065 100644
--- a/cli/src/semgrep/parsing_data.py
+++ b/cli/src/semgrep/parsing_data.py
@@ -51,6 +51,8 @@ def add_error(self, err: out.CoreError) -> None:
         given error. The file the error originated from should have been
         registered from the original plan with `add_targets`.
         """
+        if not err.location:
+            return
         path = err.location.path.value
         try:
             (lang, no_error_yet) = self._file_info[path]
diff --git a/cli/src/semgrep/resolve_subprojects.py b/cli/src/semgrep/resolve_subprojects.py
index a7ecf59d9cde..adf2c078b3ff 100644
--- a/cli/src/semgrep/resolve_subprojects.py
+++ b/cli/src/semgrep/resolve_subprojects.py
@@ -88,9 +88,47 @@
     PackageManagerType.ELIXIR_HEX: Ecosystem(out.Mix()),
 }
 
+DEPENDENCY_GRAPH_SUPPORTED_MANIFEST_KINDS = [
+    out.ManifestKind(out.PomXml()),
+    out.ManifestKind(out.BuildGradle()),
+]
+
+
+def _resolve_dependencies_dynamically(
+    manifest_path: Path, manifest_kind: out.ManifestKind
+) -> Tuple[
+    Optional[Ecosystem], List[FoundDependency], List[DependencyParserError], List[Path]
+]:
+    """
+    Handle the RPC call to resolve dependencies dynamically.
+    """
+    manifest_arg = out.Manifest(kind=manifest_kind, path=out.Fpath(str(manifest_path)))
+    response = resolve_dependencies([manifest_arg])
+    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 = 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())
+        return ecosystem, resolved_deps, [], [manifest_path]
+    else:
+        # some error occured in resolution
+        # TODO: error handling / bubbling up
+        return (None, [], [], [])
+
 
 def _resolve_dependency_source(
-    dep_source: DependencySource, enable_dynamic_resolution: bool = True
+    dep_source: DependencySource,
+    enable_dynamic_resolution: bool = True,
+    prioritize_dependency_graph_generation: bool = False,
 ) -> Tuple[
     Optional[Ecosystem], List[FoundDependency], List[DependencyParserError], List[Path]
 ]:
@@ -102,13 +140,39 @@ def _resolve_dependency_source(
     - The list of paths that should be considered dependency targets
     """
     ecosystem: Optional[Ecosystem] = None
+
     if isinstance(dep_source, LockfileDependencySource):
         parser = PARSERS_BY_PACKAGE_MANAGER_TYPE[dep_source.package_manager_type]
         ecosystem = ECOSYSTEM_BY_PACKAGE_MANAGER_TYPE[dep_source.package_manager_type]
-        resolved_deps, parse_errors = parser(
-            dep_source.lockfile_path, dep_source.manifest_path
+        manifest_kind, manifest_path = (
+            dep_source.manifest if dep_source.manifest else (None, None)
         )
-        return ecosystem, resolved_deps, parse_errors, [dep_source.lockfile_path]
+        if (
+            enable_dynamic_resolution
+            and prioritize_dependency_graph_generation
+            and manifest_path is not None
+            and manifest_kind in DEPENDENCY_GRAPH_SUPPORTED_MANIFEST_KINDS
+        ):
+            (
+                ecosystem,
+                new_deps,
+                new_errors,
+                new_targets,
+            ) = _resolve_dependencies_dynamically(manifest_path, manifest_kind)
+            if ecosystem is not None:
+                # TODO: Reimplement this once more robust error handling for lockfileless resolution is implemented
+                return ecosystem, new_deps, new_errors, new_targets
+            else:
+                # dynamic resolution failed, fall back to lockfile parsing
+                resolved_deps, parse_errors = parser(
+                    dep_source.lockfile_path, manifest_path
+                )
+            return ecosystem, resolved_deps, parse_errors, [dep_source.lockfile_path]
+        else:
+            resolved_deps, parse_errors = parser(
+                dep_source.lockfile_path, manifest_path
+            )
+            return ecosystem, resolved_deps, parse_errors, [dep_source.lockfile_path]
     elif isinstance(dep_source, MultiLockfileDependencySource):
         all_resolved_deps: List[FoundDependency] = []
         all_parse_errors: List[DependencyParserError] = []
@@ -127,29 +191,9 @@ def _resolve_dependency_source(
         isinstance(dep_source, ManifestOnlyDependencySource)
         and enable_dynamic_resolution
     ):
-        manifest_arg = out.Manifest(
-            kind=dep_source.manifest_kind, path=out.Fpath(str(dep_source.manifest_path))
+        return _resolve_dependencies_dynamically(
+            dep_source.manifest_path, dep_source.manifest_kind
         )
-        response = resolve_dependencies([manifest_arg])
-        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 = 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())
-            return ecosystem, resolved_deps, [], [dep_source.manifest_path]
-        else:
-            # some error occured in resolution
-            # TODO: error handling / bubbling up
-            return (None, [], [], [])
 
     else:
         # dependency source type is not supported, do nothing
@@ -179,6 +223,7 @@ def find_subprojects(
 def resolve_subprojects(
     target_manager: TargetManager,
     allow_dynamic_resolution: bool = False,
+    prioritize_dependency_graph_generation: bool = False,
 ) -> Tuple[
     Dict[Ecosystem, List[ResolvedSubproject]], List[DependencyParserError], List[Path]
 ]:
@@ -206,7 +251,9 @@ def resolve_subprojects(
     # Dispatch each subproject to a resolver for resolution
     for to_resolve in unresolved_subprojects:
         ecosystem, deps, errors, targets = _resolve_dependency_source(
-            to_resolve.dependency_source, allow_dynamic_resolution
+            to_resolve.dependency_source,
+            allow_dynamic_resolution,
+            prioritize_dependency_graph_generation,
         )
         dependency_parser_errors.extend(errors)
         dependency_targets.extend(targets)
diff --git a/cli/src/semgrep/run_scan.py b/cli/src/semgrep/run_scan.py
index e73dda9b7fdc..67890d8ab284 100644
--- a/cli/src/semgrep/run_scan.py
+++ b/cli/src/semgrep/run_scan.py
@@ -246,6 +246,7 @@ def run_rules(
     with_code_rules: bool = True,
     with_supply_chain: bool = False,
     allow_dynamic_dependency_resolution: bool = False,
+    prioritize_dependency_graph_generation: bool = False,
 ) -> Tuple[
     RuleMatchMap,
     List[SemgrepError],
@@ -279,6 +280,7 @@ def run_rules(
         ) = resolve_subprojects(
             target_manager,
             allow_dynamic_resolution=allow_dynamic_dependency_resolution,
+            prioritize_dependency_graph_generation=prioritize_dependency_graph_generation,
         )
 
         # Filter rules that match the dependencies
@@ -329,6 +331,7 @@ def run_rules(
                 rule.raw,
                 [target.path for target in target_manager.targets],
                 allow_dynamic_dependency_resolution=allow_dynamic_dependency_resolution,
+                prioritize_dependency_graph_generation=prioritize_dependency_graph_generation,
             )
             join_rule_matches_set = RuleMatches(rule)
             for m in join_rule_matches:
@@ -424,12 +427,18 @@ def run_rules(
 
 
 # This is used for testing and comparing with osemgrep.
-def list_targets_and_exit(target_manager: TargetManager, product: out.Product) -> None:
+def list_targets_and_exit(
+    target_manager: TargetManager, product: out.Product, long_format: bool = False
+) -> None:
     targets = target_manager.get_files_for_language(None, product)
     for path in sorted(targets.kept):
-        print(f"selected {path}")
-    for path, reason in target_manager.ignore_log.list_skipped_paths_with_reason():
-        print(f"ignored {path} [{reason}]")
+        if long_format:
+            print(f"selected {path}")
+        else:
+            print(str(path))
+    if long_format:
+        for path, reason in target_manager.ignore_log.list_skipped_paths_with_reason():
+            print(f"ignored {path} [{reason}]")
     exit(0)
 
 
@@ -484,11 +493,14 @@ def run_scan(
     baseline_commit: Optional[str] = None,
     baseline_commit_is_mergebase: bool = False,
     x_ls: bool = False,
+    x_ls_long: bool = False,
     path_sensitive: bool = False,
     capture_core_stderr: bool = True,
     enable_experimental_requirements: bool = False,
     allow_dynamic_dependency_resolution: bool = False,
     dump_n_rule_partitions: Optional[int] = None,
+    dump_rule_partitions_dir: Optional[Path] = None,
+    prioritize_dependency_graph_generation: bool = False,
 ) -> Tuple[
     RuleMatchMap,
     List[SemgrepError],
@@ -601,9 +613,9 @@ def run_scan(
 
     if dump_n_rule_partitions:
         rules = {"rules": [r.raw for r in filtered_rules]}
-        output_dir = get_state().env.user_data_folder
+        output_dir = str(dump_rule_partitions_dir)
         args = out.DumpRulePartitionsParams(
-            out.RawJson(rules), dump_n_rule_partitions, out.Fpath(str(output_dir))
+            out.RawJson(rules), dump_n_rule_partitions, out.Fpath(output_dir)
         )
         ok = dump_rule_partitions(args)
         if not ok:
@@ -678,8 +690,10 @@ def run_scan(
             ),
         )
         # Debugging option --x-ls
-        if x_ls:
-            list_targets_and_exit(target_manager, out.Product(out.SAST()))
+        if x_ls or x_ls_long:
+            list_targets_and_exit(
+                target_manager, out.Product(out.SAST()), long_format=x_ls_long
+            )
     except FilesNotFoundError as e:
         raise SemgrepError(e)
 
@@ -758,6 +772,7 @@ def run_scan(
         with_code_rules=with_code_rules,
         with_supply_chain=with_supply_chain,
         allow_dynamic_dependency_resolution=allow_dynamic_dependency_resolution,
+        prioritize_dependency_graph_generation=prioritize_dependency_graph_generation,
     )
     profiler.save("core_time", core_start_time)
     semgrep_errors: List[SemgrepError] = config_errors + scan_errors
@@ -862,6 +877,7 @@ def run_scan(
                         disable_secrets_validation,
                         baseline_target_mode_config,
                         allow_dynamic_dependency_resolution=allow_dynamic_dependency_resolution,
+                        prioritize_dependency_graph_generation=prioritize_dependency_graph_generation,
                     )
                     rule_matches_by_rule = remove_matches_in_baseline(
                         rule_matches_by_rule,
diff --git a/cli/src/semgrep/semgrep_core.py b/cli/src/semgrep/semgrep_core.py
index ce00055ca069..3abdb0f81651 100644
--- a/cli/src/semgrep/semgrep_core.py
+++ b/cli/src/semgrep/semgrep_core.py
@@ -1,5 +1,6 @@
 import importlib.resources
 import os
+import platform
 import shutil
 import sys
 from pathlib import Path
@@ -11,6 +12,8 @@
 
 VERSION_STAMP_FILENAME = "pro-installed-by.txt"
 
+IS_WINDOWS = platform.system() == "Windows"
+
 
 def compute_executable_path(exec_name: str) -> Optional[str]:
     """
@@ -18,6 +21,9 @@ def compute_executable_path(exec_name: str) -> Optional[str]:
 
     Return None if no executable found
     """
+    if IS_WINDOWS:
+        exec_name += ".exe"
+
     # First, try packaged binaries
     try:
         with importlib.resources.path("semgrep.bin", exec_name) as path:
diff --git a/cli/src/semgrep/semgrep_interfaces b/cli/src/semgrep/semgrep_interfaces
index 2f2de99cde5d..74a0c4b674b1 160000
--- a/cli/src/semgrep/semgrep_interfaces
+++ b/cli/src/semgrep/semgrep_interfaces
@@ -1,1 +1,1 @@
-Subproject commit 2f2de99cde5de1fee51bba3f700f80ba62f4ffa9
+Subproject commit 74a0c4b674b126a16c6343688eeecb4fecdecf3e
diff --git a/cli/src/semgrep/subproject.py b/cli/src/semgrep/subproject.py
index dc7a9f596efb..84aac2fcd5ec 100644
--- a/cli/src/semgrep/subproject.py
+++ b/cli/src/semgrep/subproject.py
@@ -65,7 +65,7 @@ class PackageManagerDependencySource(DependencySource):
 
 @dataclass(frozen=True)
 class LockfileDependencySource(PackageManagerDependencySource):
-    manifest_path: Optional[Path]
+    manifest: Tuple[Optional[out.ManifestKind], Optional[Path]]
     lockfile_path: Path
 
     def get_display_paths(self) -> List[Path]:
diff --git a/cli/src/semgrep/target_manager.py b/cli/src/semgrep/target_manager.py
index 8c6f133f6b82..908bcc8b8745 100644
--- a/cli/src/semgrep/target_manager.py
+++ b/cli/src/semgrep/target_manager.py
@@ -116,6 +116,7 @@ class FileTargetingLog:
     always_skipped: Set[Path] = Factory(set)
     cli_includes: Set[Path] = Factory(set)
     cli_excludes: Set[Path] = Factory(set)
+    insufficient_permissions: Set[Path] = Factory(set)
     size_limit: Set[Path] = Factory(set)
 
     # "None" indicates that all lines were skipped
@@ -163,6 +164,8 @@ def list_skipped_paths_with_reason(self) -> List[Tuple[Path, str]]:
             res.append((x, "cli_include_flags_do_not_match"))
         for x in self.cli_excludes:
             res.append((x, "cli_exclude_flags_match"))
+        for x in self.insufficient_permissions:
+            res.append((x, "insufficient_permissions"))
         for x in self.size_limit:
             res.append((x, "exceeded_size_limit"))
         return sorted(res)
@@ -201,10 +204,15 @@ def __str__(self) -> str:
             skip_fragments.append(
                 f"{len(self.cli_excludes)} files matching --exclude patterns"
             )
+        if self.insufficient_permissions:
+            skip_fragments.append(
+                f"{len(self.insufficient_permissions)} files without read permission"
+            )
         if self.size_limit:
             skip_fragments.append(
                 f"{len(self.size_limit)} files larger than {self.target_manager.max_target_bytes / 1000 / 1000} MB"
             )
+
         if self.semgrepignored:
             skip_fragments.append(
                 f"{len(self.semgrepignored)} files matching .semgrepignore patterns"
@@ -285,6 +293,16 @@ def yield_verbose_lines(
         else:
             yield 2, "<none>"
 
+        yield (
+            1,
+            f"Files skipped due to insufficient read permissions:",
+        )
+        if self.insufficient_permissions:
+            for path in sorted(self.insufficient_permissions):
+                yield 2, with_color(Colors.cyan, str(path))
+        else:
+            yield 2, "<none>"
+
         yield (
             1,
             f"Skipped by limiting to files smaller than {self.target_manager.max_target_bytes} bytes:",
@@ -362,6 +380,8 @@ def yield_json_objects(self) -> Iterable[Dict[str, Any]]:
             yield {"path": str(path), "reason": "cli_include_flags_do_not_match"}
         for path in self.cli_excludes:
             yield {"path": str(path), "reason": "cli_exclude_flags_match"}
+        for path in self.insufficient_permissions:
+            yield {"path": str(path), "reason": "insufficient_permissions"}
         for path in self.size_limit:
             yield {
                 "path": str(path),
@@ -571,7 +591,7 @@ def preprocess_path_patterns(patterns: Sequence[str]) -> List[str]:
         In semgrep, pattern "foo/bar" should match paths "x/foo/bar", "foo/bar/x", and
         "x/foo/bar/x". It implicitly matches zero or more directories at the beginning and the end
         of the pattern. In contrast, we have to explicitly specify the globstar (**) patterns in
-        wcmatch. This function will converts a pattern "foo/bar" into "**/foo/bar" and
+        wcmatch. This function will convert a pattern "foo/bar" into "**/foo/bar" and
         "**/foo/bar/**". We need the pattern without the trailing "/**" because "foo/bar.py/**"
         won't match "foo/bar.py".
         """
@@ -681,6 +701,18 @@ def filter_excludes(
 
         return FilteredFiles(frozenset(candidates - removed), frozenset(removed))
 
+    @staticmethod
+    def filter_by_permission(candidates: FrozenSet[Path]) -> FilteredFiles:
+        """
+        Exclude files we can't read
+        """
+        kept, removed = partition(
+            candidates,
+            lambda path: os.access(path, os.R_OK),
+        )
+
+        return FilteredFiles(frozenset(kept), frozenset(removed))
+
     @staticmethod
     def filter_by_size(
         max_target_bytes: int, *, candidates: FrozenSet[Path]
@@ -720,7 +752,7 @@ def get_files_for_language(
         Return all files that are decendants of any directory in TARGET that have
         an extension matching LANG or are a lockfile for LANG ecosystem that match any pattern in INCLUDES and do not
         match any pattern in EXCLUDES. Any file in TARGET bypasses excludes and includes.
-        If a file in TARGET has a known extension that is not for langugage LANG then
+        If a file in TARGET has a known extension that is not for language LANG then
         it is also filtered out.
 
         Lang can be:
@@ -744,6 +776,14 @@ def get_files_for_language(
         else:
             files = FilteredFiles(frozenset(all_files), frozenset())
 
+        ####################################################################
+        # language-independent, rule-independent target filtering
+        # TODO: generic filtering should not be done here!
+        # This prevents us from getting the count of targets before filtering
+        # them further based on applicable rules. This number is reported
+        # correctly by osemgrep but incorrectly by pysemgrep.
+        ####################################################################
+
         files = self.filter_includes(self.includes, candidates=files.kept)
         self.ignore_log.cli_includes.update(files.removed)
 
@@ -755,6 +795,9 @@ def get_files_for_language(
         files = self.filter_excludes(PATHS_ALWAYS_SKIPPED, candidates=files.kept)
         self.ignore_log.always_skipped.update(files.removed)
 
+        files = self.filter_by_permission(files.kept)
+        self.ignore_log.insufficient_permissions.update(files.removed)
+
         # Lockfiles are easy to parse, and regularly surpass 1MB for big repos
         if lang != "dependency_source_files":
             files = self.filter_by_size(self.max_target_bytes, candidates=files.kept)
@@ -798,7 +841,7 @@ def get_files_for_rule(
 
         Given this object's TARGET, self.INCLUDE, and self.EXCLUDE will return list
         of all descendant files of directories in TARGET that end in extension
-        typical for LANG. If self.INCLUDES is non empty then all files will have an ancestor
+        typical for LANG. If self.INCLUDES is nonempty then all files will have an ancestor
         that matches a pattern in self.INCLUDES. Will not include any file that has
         an ancestor that matches a pattern in self.EXCLUDES. Any explicitly named files
         in TARGET will bypass this global INCLUDE/EXCLUDE filter. The local INCLUDE/EXCLUDE
diff --git a/cli/src/semgrep/tracing.py b/cli/src/semgrep/tracing.py
index 0c70e66e6a09..ba93ed96bdc1 100644
--- a/cli/src/semgrep/tracing.py
+++ b/cli/src/semgrep/tracing.py
@@ -13,12 +13,17 @@
 from opentelemetry import trace as otrace
 from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
 from opentelemetry.instrumentation.requests import RequestsInstrumentor
+from opentelemetry.sdk.resources import OTELResourceDetector
+from opentelemetry.sdk.resources import ProcessResourceDetector
 from opentelemetry.sdk.resources import Resource
 from opentelemetry.sdk.resources import SERVICE_NAME
+from opentelemetry.sdk.resources import SERVICE_VERSION
 from opentelemetry.sdk.trace import TracerProvider
 from opentelemetry.sdk.trace.export import BatchSpanProcessor
 from typing_extensions import ParamSpec
 
+from semgrep import __VERSION__
+
 TRACER = otrace.get_tracer(__name__)
 
 # Coupling: these constants need to be kept in sync with Tracing.ml
@@ -36,6 +41,12 @@
     "semgrep-local": _LOCAL_ENDPOINT,
 }
 
+_ENV_ALIASES = {
+    "semgrep-prod": "prod",
+    "semgrep-dev": "dev2",
+    "semgrep-local": "local",
+}
+
 
 @define
 class Traces:
@@ -47,8 +58,20 @@ def configure(self, enabled: bool, trace_endpoint: Optional[str]) -> None:
         if not self.enabled:
             return
 
-        resource = Resource(attributes={SERVICE_NAME: "semgrep-cli"})
+        env_name = _ENV_ALIASES.get(
+            _DEFAULT_ENDPOINT if trace_endpoint is None else trace_endpoint
+        )
+        resource = Resource(
+            [ProcessResourceDetector(), OTELResourceDetector()],  # type: ignore
+            attributes={
+                SERVICE_NAME: "semgrep-cli",
+                SERVICE_VERSION: __VERSION__,
+                "deployment.environment.name": env_name if env_name else "prod",
+            },
+        )
+
         tracer_provider = TracerProvider(resource=resource)
+
         otrace.set_tracer_provider(tracer_provider)
 
         endpoint = (
@@ -57,8 +80,10 @@ def configure(self, enabled: bool, trace_endpoint: Optional[str]) -> None:
             else _DEFAULT_ENDPOINT
         )
         exporter = OTLPSpanExporter(endpoint + "/v1/traces")
+
         processor = BatchSpanProcessor(exporter)
         tracer_provider.add_span_processor(processor)
+
         RequestsInstrumentor().instrument()
 
         self.extract()
diff --git a/languages/bash/tree-sitter/Parse_bash_tree_sitter.ml b/languages/bash/tree-sitter/Parse_bash_tree_sitter.ml
index 507f028f856b..128f5774729e 100644
--- a/languages/bash/tree-sitter/Parse_bash_tree_sitter.ml
+++ b/languages/bash/tree-sitter/Parse_bash_tree_sitter.ml
@@ -1482,7 +1482,7 @@ let parse file =
       let env =
         { H.file; conv = H.line_col_to_pos file; extra = AST_bash.Program }
       in
-      let tok = Tok.first_tok_of_file !!file in
+      let tok = Tok.first_tok_of_file file in
       program env ~tok cst)
 
 let parse_pattern str =
@@ -1497,5 +1497,5 @@ let parse_pattern str =
           extra = AST_bash.Pattern;
         }
       in
-      let tok = Tok.first_tok_of_file !!file in
+      let tok = Tok.first_tok_of_file file in
       program env ~tok cst)
diff --git a/languages/java/tree-sitter/Parse_java_tree_sitter.ml b/languages/java/tree-sitter/Parse_java_tree_sitter.ml
index 28a54704110d..0e977c30f036 100644
--- a/languages/java/tree-sitter/Parse_java_tree_sitter.ml
+++ b/languages/java/tree-sitter/Parse_java_tree_sitter.ml
@@ -1927,7 +1927,7 @@ let partials (env : env) (x : CST.partials) =
 let program (env : env) (file : Fpath.t) (x : CST.program) =
   match x with
   | `Rep_stmt xs ->
-      let tok = Tok.first_tok_of_file !!file in
+      let tok = Tok.first_tok_of_file file in
       AProgram (List_.map (statement env ~tok) xs)
   | `Cons_decl x -> AStmt (DeclStmt (Method (constructor_declaration env x)))
   | `Exp x -> AExpr (expression env x)
diff --git a/languages/ocaml/ast/AST_ocaml.ml b/languages/ocaml/ast/AST_ocaml.ml
index 7d8ddaa5c3f5..3f10fe89452c 100644
--- a/languages/ocaml/ast/AST_ocaml.ml
+++ b/languages/ocaml/ast/AST_ocaml.ml
@@ -95,7 +95,6 @@ type expr =
    *)
   | Constructor of name * expr option
   | PolyVariant of (Tok.t (* '`' *) * ident) * expr option
-  | Obj of object_
   (* special case of Constr *)
   | Tuple of expr list
   | List of expr list bracket
@@ -106,17 +105,10 @@ type expr =
    * below will be used instead.
    *)
   | Sequence of expr list bracket (* begin/end, (), or do/done *)
-  (* In most ASTs, and in most parsers, we get rid of those Paren
-   * constructs because they are more useful in CSTs than ASTs. However,
-   * for semgrep, and especially for autofix, parenthesis matter.
-   * We used to have 'Tuple of expr list bracket', but this was not enough
-   * because in code like Foo (1+2), there are no tuple, but
-   * we still want to remember those parenthesis in the AST.
-   * Thus, we keed those parenthesis in the AST and let ocaml_to_generic.ml
-   * do the right thing depending on the context in which those
-   * ParenExpr are used (for Tuple, for Constructor, or for regular grouping).
-   *)
-  | ParenExpr of expr bracket
+  (* we unsugar { x } in { x = x } *)
+  | Record of expr option (* with *) * (name * expr) list bracket
+  | Obj of object_
+  | New of Tok.t * name
   (* todo: Use AST_generic_.op? *)
   | Prefix of string wrap * expr
   | Infix of expr * string wrap * expr
@@ -128,14 +120,12 @@ type expr =
   (* special case of RefAccess and RefAssign *)
   | FieldAccess of expr * Tok.t * name
   | FieldAssign of expr * Tok.t * name * Tok.t (* <- *) * expr
-  (* we unsugar { x } in { x = x } *)
-  | Record of expr option (* with *) * (name * expr) list bracket
-  | New of Tok.t * name
   | ObjAccess of expr * Tok.t (* # *) * ident
   (* > 1 elt for mutually recursive let (let x and y and z) *)
   | LetIn of Tok.t * rec_opt * let_binding list * expr
   | Fun of Tok.t (* 'fun' *) * parameter list (* at least one *) * expr
   | Function of Tok.t (* 'function' *) * match_case list
+  (* those are expressions in OCaml! composability! no need for statement *)
   | If of Tok.t * expr * expr * expr option
   | Match of Tok.t * expr * match_case list
   | Try of Tok.t * expr * match_case list
@@ -143,11 +133,25 @@ type expr =
   | For of Tok.t * ident * expr * for_direction * expr * expr
   (* regular construct but also semgrep-ext: for Typed metavariables *)
   | TypedExpr of expr * Tok.t (* : *) * type_
+  (* OCaml 4.?? *)
+  | LetOpen of Tok.t (* 'let' *) * name * Tok.t (* 'in' *) * expr
+  | LocalOpen of name * Tok.t (* '.' *) * expr
+  (* In most ASTs, and in most parsers, we get rid of those Paren
+   * constructs because they are more useful in CSTs than ASTs. However,
+   * for semgrep, and especially for autofix, parenthesis matter.
+   * We used to have 'Tuple of expr list bracket', but this was not enough
+   * because in code like Foo (1+2), there are no tuple, but
+   * we still want to remember those parenthesis in the AST.
+   * Thus, we keed those parenthesis in the AST and let ocaml_to_generic.ml
+   * do the right thing depending on the context in which those
+   * ParenExpr are used (for Tuple, for Constructor, or for regular grouping).
+   *)
+  | ParenExpr of expr bracket
   (* sgrep-ext: *)
   | Ellipsis of Tok.t
   | DeepEllipsis of expr bracket
   (* TODO:
-   * - LocalOpen, LocalModule
+   * - LocalModule
    * - Array, BigArray (literals, access, set)
    * - Lazy, Assert,
    * - Object, ObjCopy
@@ -242,7 +246,7 @@ and pattern =
   (* sgrep-ext: *)
   | PatEllipsis of Tok.t
   (* TODO:
-   * - LocalOpen,
+   * - PatLocalOpen,
    * - Array, BigArray,
    * - Lazy
    *)
@@ -410,7 +414,7 @@ and item_kind =
   | Type of Tok.t * type_declaration list (* mutually recursive *)
   | Exception of Tok.t * ident * type_ list
   | External of Tok.t * ident * type_ * string wrap list (* primitive decls *)
-  (* TODO: '!' option *)
+  (* TODO: '!' option, and the general open module_expr *)
   | Open of Tok.t * name
   (* only in sig_item *)
   | Val of Tok.t * ident * type_
diff --git a/languages/ocaml/generic/ocaml_to_generic.ml b/languages/ocaml/generic/ocaml_to_generic.ml
index ac2117d0d4be..f3effeaed3ef 100644
--- a/languages/ocaml/generic/ocaml_to_generic.ml
+++ b/languages/ocaml/generic/ocaml_to_generic.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2019, 2020 r2c
+ * Copyright (C) 2019, 2020, 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
@@ -233,6 +233,14 @@ and option_expr_to_ctor_arguments v =
 
 and expr e =
   match e with
+  | LetOpen (tlet, n, _tin, e) ->
+      let dotted = module_name n in
+      let e = expr e in
+      G.LocalImportAll (G.DottedName dotted, tlet, e) |> G.e
+  | LocalOpen (n, tdot, e) ->
+      let dotted = module_name n in
+      let e = expr e in
+      G.LocalImportAll (G.DottedName dotted, tdot, e) |> G.e
   | Obj { o_tok; o_body } ->
       let cdef =
         G.
diff --git a/languages/ocaml/tree-sitter/Parse_ocaml_tree_sitter.ml b/languages/ocaml/tree-sitter/Parse_ocaml_tree_sitter.ml
index 3cb9c96f9665..19974936d511 100644
--- a/languages/ocaml/tree-sitter/Parse_ocaml_tree_sitter.ml
+++ b/languages/ocaml/tree-sitter/Parse_ocaml_tree_sitter.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (c) 2021-2022 R2C
+ * Copyright (c) 2021-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
@@ -918,22 +918,19 @@ and map_binding_pattern (env : env) (x : CST.binding_pattern) : pattern =
       let v2 = token env v2 (* "." *) in
       let v3 =
         match v3 with
-        | `LPAR_opt_bind_pat_ext_RPAR (v1, v2, v3) ->
+        | `LPAR_opt_bind_pat_ext_RPAR (v1, v2, v3) -> (
             let v1 = token env v1 (* "(" *) in
             let _v3 = token env v3 (* ")" *) in
-            let v2 =
-              match v2 with
-              | Some x ->
-                  let p = map_binding_pattern_ext env x in
-                  PatTodo (("LocalOpen", v1), [ p ])
-              | None -> PatTodo (("LocalOpen", v1), [])
-            in
-            v2
+            match v2 with
+            | Some x ->
+                let p = map_binding_pattern_ext env x in
+                PatTodo (("PatLocalOpen", v1), [ p ])
+            | None -> PatTodo (("PatLocalOpen", v1), []))
         | `List_bind_pat x -> map_list_binding_pattern env x
         | `Array_bind_pat x -> map_array_binding_pattern env x
         | `Record_bind_pat x -> map_record_binding_pattern env x
       in
-      PatTodo (("LocalOpen", v2), [ v3 ])
+      PatTodo (("PatLocalOpen", v2), [ v3 ])
   | `Pack_pat x -> map_package_pattern env x
   | `Paren_bind_pat (v1, v2, v3) ->
       let _v1 = token env v1 (* "(" *) in
@@ -1499,12 +1496,14 @@ and map_expression (env : env) (x : CST.expression) : expr =
       let _v3 = token env v3 (* "in" *) in
       let v4 = map_sequence_expression_ext env v4 |> seq1 in
       ExprTodo (("LocalModule", v1), [ v4 ])
-  | `Let_open_exp (v1, v2, v3, v4) ->
-      let v1 = token env v1 (* "let" *) in
-      let _v2 = map_open_module env v2 in
-      let _v3 = token env v3 (* "in" *) in
-      let v4 = map_sequence_expression_ext env v4 |> seq1 in
-      ExprTodo (("LocalOpen", v1), [ v4 ])
+  | `Let_open_exp (v1, v2, v3, v4) -> (
+      let tlet = token env v1 (* "let" *) in
+      let mod_ = map_open_module env v2 in
+      let tin = token env v3 (* "in" *) in
+      let e = map_sequence_expression_ext env v4 |> seq1 in
+      match mod_ with
+      | { i = Open (_topen, n); _ } -> LetOpen (tlet, n, tin, e)
+      | _else_ -> ExprTodo (("LetLocalOpenModExpr", tlet), [ e ]))
   | `Let_exc_exp (v1, v2, v3, v4) ->
       let v1 = token env v1 (* "let" *) in
       let _v2 = map_exception_definition env v2 in
@@ -1890,7 +1889,8 @@ and map_module_definition (env : env)
   | [] -> Module (v1, v4) |> mki
   | _ -> ItemTodo (("Modules", v1), []) |> mki
 
-and map_module_expression (env : env) (x : CST.module_expression) =
+and map_module_expression (env : env) (x : CST.module_expression) : module_expr
+    =
   match x with
   | `Simple_module_exp x -> map_simple_module_expression env x
   | `Module_path x ->
@@ -1923,7 +1923,8 @@ and map_module_expression (env : env) (x : CST.module_expression) =
       in
       ModuleTodo (("App", Tok.unsafe_fake_tok ""), v1 :: v2)
 
-and map_module_expression_ext (env : env) (x : CST.module_expression_ext) =
+and map_module_expression_ext (env : env) (x : CST.module_expression_ext) :
+    module_expr =
   match x with
   | `Module_exp x -> map_module_expression env x
   | `Exte x ->
@@ -2602,27 +2603,43 @@ and map_simple_expression (env : env) (x : CST.simple_expression) : expr =
       let _v5 = map_type_ext env v5 in
       let _v6 = token env v6 (* ")" *) in
       ExprTodo (("Coerce", v1), [ v2 ])
-  | `Local_open_exp (v1, v2, v3) ->
-      let _v1 = map_module_path env v1 in
-      let v2 = token env v2 (* "." *) in
-      let v3 =
-        match v3 with
-        | `LPAR_opt_seq_exp_ext_RPAR (v1, v2, v3) ->
-            let _v1 = token env v1 (* "(" *) in
-            let v2 =
-              match v2 with
-              | Some x -> map_sequence_expression_ext env x
-              | None -> []
-            in
-            let _v3 = token env v3 (* ")" *) in
-            v2
-        | `List_exp x -> [ map_list_expression env x ]
-        | `Array_exp x -> [ map_array_expression env x ]
-        | `Record_exp x -> [ map_record_expression env x ]
-        | `Obj_copy_exp x -> [ map_object_copy_expression env x ]
-        | `Pack_exp x -> [ map_package_expression env x ]
-      in
-      ExprTodo (("LocalOpen", v2), v3)
+  | `Local_open_exp (v1, v2, v3) -> (
+      let qu = map_module_path env v1 in
+      let n = AST_ocaml.qualifier_to_name qu in
+      let tdot = token env v2 (* "." *) in
+      match v3 with
+      | `LPAR_opt_seq_exp_ext_RPAR (v1, v2, v3) ->
+          let lp = token env v1 (* "(" *) in
+          let rp = token env v3 (* ")" *) in
+          let xs =
+            match v2 with
+            | Some e -> map_sequence_expression_ext env e
+            | None -> []
+          in
+          let e =
+            match xs with
+            | [ x ] -> ParenExpr (lp, x, rp)
+            | xs -> Sequence (lp, xs, rp)
+          in
+          LocalOpen (n, tdot, e)
+      (* those do not require the extra () as they already use
+       * delimiters (e.g., '[]' for map_list_expression)
+       *)
+      | `List_exp x ->
+          let e = map_list_expression env x in
+          LocalOpen (n, tdot, e)
+      | `Array_exp x ->
+          let e = map_array_expression env x in
+          LocalOpen (n, tdot, e)
+      | `Record_exp x ->
+          let e = map_record_expression env x in
+          LocalOpen (n, tdot, e)
+      | `Obj_copy_exp x ->
+          let e = map_object_copy_expression env x in
+          LocalOpen (n, tdot, e)
+      | `Pack_exp x ->
+          let e = map_package_expression env x in
+          LocalOpen (n, tdot, e))
   | `Pack_exp x -> map_package_expression env x
   | `New_exp (v1, v2, v3) ->
       let v1 = token env v1 (* "new" *) in
diff --git a/languages/yaml/generic/yaml_to_generic.ml b/languages/yaml/generic/yaml_to_generic.ml
index 01e8a1eb9171..0b62d3e4dca9 100644
--- a/languages/yaml/generic/yaml_to_generic.ml
+++ b/languages/yaml/generic/yaml_to_generic.ml
@@ -170,7 +170,7 @@ let error str v pos env =
   let t = mk_tok pos (p_token v) env in
   raise (Parsing_error.Other_error (str, t))
 
-let get_res file = function
+let get_res (file : Fpath.t) = function
   | Result.Error (`Msg str) ->
       let loc = Tok.first_loc_of_file file in
       let tok = Tok.tok_of_loc loc in
@@ -184,7 +184,7 @@ let do_parse env =
       let prefix, tok =
         match env.last_event with
         | None ->
-            let loc = Tok.first_loc_of_file !!(env.file) in
+            let loc = Tok.first_loc_of_file env.file in
             ("(incorrect error location) ", Tok.tok_of_loc loc)
         | Some (v, pos) ->
             ( "(approximate error location; error nearby after) ",
@@ -631,7 +631,7 @@ let parse_yaml_file ~is_target (file : Fpath.t) str =
   let bytepos_to_pos =
     Some (Pos.full_converters_large !!file).bytepos_to_linecol_fun
   in
-  let parser = get_res !!file (S.parser str) in
+  let parser = get_res file (S.parser str) in
   let env =
     {
       file;
@@ -651,7 +651,7 @@ let parse_yaml_file ~is_target (file : Fpath.t) str =
 let any str =
   let file = Fpath.v "<pattern_file>" in
   let str = preprocess_yaml (mask_unicode str) in
-  let parser = get_res !!file (S.parser str) in
+  let parser = get_res file (S.parser str) in
   let env =
     {
       file;
diff --git a/libs/ast_generic/AST_generic.ml b/libs/ast_generic/AST_generic.ml
index 458854ebdfff..0f86be24883e 100644
--- a/libs/ast_generic/AST_generic.ml
+++ b/libs/ast_generic/AST_generic.ml
@@ -28,7 +28,7 @@
  *  - Java, Apex, Kotlin, C#
  *  - C, C++
  *  - Go
- *  - Swift
+ *  - Swift, Dart
  *  - OCaml, Scala, Rust
  *  - Clojure, Lisp, Scheme
  *  - R
@@ -37,12 +37,13 @@
  *  - JSON, XML, YAML
  *  - Jsonnet, Terraform
  *  - HTML
- *  - Promql
+ *  - Promql, CodeQL
  * TODO: SQL, Sqlite, PostgresSQL, Protobuf
  *
  * See Lang.ml for the list of supported languages.
  * See IL.ml for a generic IL (Intermediate language) better suited for
  * advanced static analysis (e.g., tainted dataflow analysis).
+ * See SAST.ml in pro a simplification of this AST better suited for naming.
  *
  * rational: In the end, programming languages have a lot in Common.
  * Even though some interesting analysis are probably better done on a
@@ -66,6 +67,8 @@
  *    parens around conditions in if). We keep the parens for 'Call'
  *    because we want to get the right range for those so we need the
  *    rightmost tokens in the AST.
+ *    TODO: long term we should not keep any tokens and maintain instead the
+ *    range information in each node (which would be faster and cleaner).
  *  - multiple var declarations in one declaration (e.g., int a,b; in C)
  *    are expanded in multiple 'variable_definition'. Note that
  *    tuple assignments (e.g., a,b=1,2) are not expanded in multiple assigns
@@ -95,7 +98,8 @@
  *
  * Note that this generic AST has become gradually more and more a
  * generic CST, to fix issues in autofix in Semgrep.
- * TODO? it may be time to rename this file CST_generic.ml
+ * TODO? it may be time to rename this file CST_generic.ml (or to remove
+ * all the tokens so we're back to be an AST).
  *
  * related work:
  *  - lib_parsing/ast_fuzzy.ml
@@ -174,7 +178,7 @@
  * convenient to correspond mostly to Semgrep versions. So version below
  * can jump from "1.12.1" to "1.20.0" and that's fine.
  *)
-let version = "1.66.2"
+let version = "1.93.0"
 
 (*****************************************************************************)
 (* Some notes on deriving *)
@@ -329,8 +333,8 @@ and resolved_name_kind =
    * aliased when imported.
    * both dotted_ident must at least contain one element *)
   | ImportedEntity of canonical_name
-  | ImportedModule of
-      canonical_name (* just the DottedName part of module_name*)
+  (* just the DottedName part of module_name *)
+  | ImportedModule of canonical_name
   (* used in Go, where you can pass types as arguments and where we
    * need to resolve those cases
    *)
@@ -710,6 +714,13 @@ and expr_kind =
    * how user think.
    *)
   | Constructor of name * expr list bracket
+  (* see also New(...) for other values *)
+  | N of name
+  | IdSpecial of special wrap
+  (* operators and function application *)
+  | Call of expr * arguments
+  (* IntepolatedString of expr list is simulated with a
+   * Call(IdSpecial (Concat ...)) *)
   (* Regexp templates are interpolated strings. Constant regexps aren't
      represented here but under 'literal' so as to benefit from constant
      propagation. This is meant to be similar to how string literals
@@ -723,11 +734,6 @@ and expr_kind =
      - semgrep metavariable ($X and $...X)
   *)
   | RegexpTemplate of expr bracket (* // *) * string wrap option (* modifiers *)
-  (* see also New(...) for other values *)
-  | N of name
-  | IdSpecial of special wrap
-  (* operators and function application *)
-  | Call of expr * arguments
   (* 'type_' below is usually a TyN or TyArray (or TyExpr).
    * 'id_info' refers to the constructor.
    * Note that certain languages do not have a 'new' keyword
@@ -741,8 +747,6 @@ and expr_kind =
   (* TODO? Separate regular Calls from OpCalls where no need bracket and Arg *)
   (* (XHP, JSX, TSX), could be transpiled also (done in IL.ml?) *)
   | Xml of xml
-  (* IntepolatedString of expr list is simulated with a
-   * Call(IdSpecial (Concat ...)) *)
   (* The left part should be an lvalue (Id, DotAccess, ArrayAccess, Deref)
    * but it can also be a pattern (Container, even Record), but
    * you should really use LetPattern for that.
@@ -811,6 +815,10 @@ and expr_kind =
      Revisit when symbolic propagation is more stable
   *)
   | Alias of string wrap * expr
+  (* For local open in OCaml, as in `let open Foo in` or the more
+   * recent Foo.(...) and Foo.[...]
+   *)
+  | LocalImportAll of module_name * tok (* '.' or 'let' *) * expr
   (* sgrep: ... in expressions, args, stmts, items, and fields
    * (and unfortunately also in types in Python) *)
   | Ellipsis of tok (* '...' *)
@@ -2051,9 +2059,11 @@ and directive_kind =
       * (ident * alias option (* as name alias *)) list
   | ImportAs of tok * module_name * alias option (* as name *)
   (* Bad practice! hard to resolve name locally.
-   * We use ImportAll for C/C++ #include and C++ 'using namespace'.
+   * We use ImportAll for C/C++ '#include', C++ 'using namespace', wildcard
+   * imports in Java, 'open' in OCaml, etc.
    * The last tok is '.' in Go, '*' in Java/Python, '_' in Scala, and a fake
    * token in C++ 'using namespace std;'.
+   * See also LocalImportAll in the expr type.
    *)
   | ImportAll of tok * module_name * tok
   (* packages are different from modules in that multiple files can reuse
diff --git a/libs/ast_generic/AST_generic_to_v1.ml b/libs/ast_generic/AST_generic_to_v1.ml
index 31a43afa74f5..75ca89958679 100644
--- a/libs/ast_generic/AST_generic_to_v1.ml
+++ b/libs/ast_generic/AST_generic_to_v1.ml
@@ -346,6 +346,7 @@ and map_expr x : B.expr =
       let v1 = map_tok v1 in
       `Ellipsis v1
   | DeepEllipsis (v1, _, _) -> raise (SemgrepConstruct v1)
+  | LocalImportAll _ -> failwith "TODO"
   | OtherExpr (v1, v2) ->
       let v1 = map_todo_kind v1 in
       let v2 = map_of_list map_any v2 in
diff --git a/libs/ast_generic/Meta_AST.ml b/libs/ast_generic/Meta_AST.ml
index 92c6d3c5e8e0..ca4ded9251bb 100644
--- a/libs/ast_generic/Meta_AST.ml
+++ b/libs/ast_generic/Meta_AST.ml
@@ -301,6 +301,11 @@ and vof_name = function
 and vof_expr e =
   (* TODO: also dump e_id? *)
   match e.e with
+  | LocalImportAll (v1, v2, v3) ->
+      let v1 = vof_module_name v1 in
+      let v2 = vof_tok v2 in
+      let v3 = vof_expr v3 in
+      OCaml.VSum ("LocalImportAll", [ v1; v2; v3 ])
   | DotAccessEllipsis (v1, v2) ->
       let v1 = vof_expr v1 in
       let v2 = vof_tok v2 in
diff --git a/libs/git_wrapper/Git_wrapper.ml b/libs/git_wrapper/Git_wrapper.ml
index 1cb41f3cde45..ae8efe74753f 100644
--- a/libs/git_wrapper/Git_wrapper.ml
+++ b/libs/git_wrapper/Git_wrapper.ml
@@ -714,19 +714,72 @@ let project_url ?cwd () : string option =
   match UCmd.string_of_run ~trim:true cmd with
   | Ok (url, _) -> Some (Uri.to_string (clean_project_url url))
   | Error _ ->
+      (* TODO(dinosaure): this line is pretty weak due to the [".com"] (what
+         happens when the domain is [".io"]?). We probably should handle that by a
+         new environment variable. I just copied what [pysemgrep] does.
+         [git ls-remote --get-url] is also enough and if we can not get such
+         information, that's fine - the metadata is used only [Metrics_] actually.
+      *)
       UFile.find_first_match_with_whole_line (Fpath.v ".git/config") ".com"
 
-(* TODO(dinosaure): this line is pretty weak due to the [".com"] (what happens
-   when the domain is [".io"]?). We probably should handle that by a new
-   environment variable. I just copied what [pysemgrep] does.
-   [git ls-remote --get-url] is also enough and if we can not get such
-   information, that's fine - the metadata is used only [Metrics_] actually. *)
+(* coupling: with semgrep_output_v1.atd contribution type, but
+ * duplicated here to avoid depending on semgrep-specific code from libs/
+ * alt: move logs() below to Parse_contribution.ml which would avoid some
+ * duplication or pass the format string as a parameter and handle the
+ * parsing in the caller
+ *)
+type contribution = {
+  commit_hash : string;
+  commit_timestamp : string;
+      (** use the ISO 8601 format (use Timedesc.Timestamp.of_iso8601) *)
+  commit_author_name : string;
+  commit_author_email : string;
+}
+[@@deriving show]
 
-(* coupling: with semgrep_output_v1.atd contribution type *)
-let git_log_json_format =
-  "--pretty=format:{\"commit_hash\": \"%H\", \"commit_timestamp\": \"%aI\", \
-   \"contributor\": {\"commit_author_name\": \"%an\", \"commit_author_email\": \
-   \"%ae\"}}"
+(* old: was using some --pretty=format:{commit_hash: %H ... to generate
+ * directly some JSON that semgrep_output_v1.atd contributions type could read,
+ * but this was not dealing correctly with special chars (e.g., an
+ * backslash char in the author) so better to not abuse JSON and use
+ * the special null byte char to separate fields.
+ *)
+let git_log_format = "--pretty=format:%H%x00%aI%x00%an%x00%ae"
+
+let parse_git_log_format_line (line : string) : contribution option =
+  match String.split_on_char '\000' line with
+  | [ commit_hash; commit_timestamp; commit_author_name; commit_author_email ]
+    ->
+      Some
+        {
+          commit_hash;
+          commit_timestamp;
+          commit_author_name;
+          commit_author_email;
+        }
+  | _else_ ->
+      (* nosemgrep: no-logs-in-library *)
+      Logs.warn (fun m -> m "wrong git log line: %s" line);
+      None
+
+let () =
+  Testo.test "git log contribution line parsing" (fun () ->
+      let check a b =
+        if not (a =*= b) then
+          failwith (spf "got %s" ([%show: contribution option] a))
+      in
+      let res =
+        parse_git_log_format_line "aa\0002024-01-01\000foo\\bar\000stuff@bar"
+      in
+      let expected =
+        Some
+          {
+            commit_hash = "aa";
+            commit_timestamp = "2024-01-01";
+            commit_author_name = "foo\\bar";
+            commit_author_email = "stuff@bar";
+          }
+      in
+      check res expected)
 
 let time_to_str (timestamp : float) : string =
   let date = Unix.gmtime timestamp in
@@ -735,26 +788,24 @@ let time_to_str (timestamp : float) : string =
   let day = date.tm_mday in
   Printf.sprintf "%04d-%02d-%02d" year month day
 
-(* TODO: should really return a JSON.t list at least *)
-let logs ?cwd ?since (_caps_exec : < Cap.exec >) : string list =
+let logs ?cwd ?since (_caps_exec : < Cap.exec >) : contribution list =
   let cmd : Cmd.t =
     match since with
-    | None -> (git, cd cwd @ [ "log"; git_log_json_format ])
+    | None -> (git, cd cwd @ [ "log"; git_log_format ])
     | Some time ->
         let after = spf "--after=\"%s\"" (time_to_str time) in
-        (git, cd cwd @ [ "log"; after; git_log_json_format ])
+        (git, cd cwd @ [ "log"; after; git_log_format ])
   in
-  let lines =
-    match UCmd.lines_of_run ~trim:true cmd with
-    (* ugly: we should parse those lines and return a proper type,
-     * at least a JSON.t.
-     *)
-    | Ok (lines, (_, `Exited 0)) -> lines
-    (* TODO: maybe should raise an Error instead *)
-    | _ -> []
-  in
-  (* out_lines splits on newlines, so we always have an extra space at the end *)
-  List.filter (fun f -> not (String.trim f = "")) lines
+  match UCmd.lines_of_run ~trim:true cmd with
+  | Ok (lines, (_, `Exited 0)) ->
+      (* out_lines splits on newlines, so always an extra space at the end *)
+      lines
+      |> List.filter (fun f -> not (String.trim f = ""))
+      |> List_.filter_map parse_git_log_format_line
+  | _ ->
+      (* nosemgrep: no-logs-in-library *)
+      Logs.warn (fun m -> m "running %s failed" (Cmd.show cmd));
+      []
 
 let cat_file_blob ?cwd (hash : hash) =
   let cmd = (git, cd cwd @ [ "cat-file"; "blob"; Hash.to_hex hash ]) in
diff --git a/libs/git_wrapper/Git_wrapper.mli b/libs/git_wrapper/Git_wrapper.mli
index c90deb8b793a..77959b6ddfc2 100644
--- a/libs/git_wrapper/Git_wrapper.mli
+++ b/libs/git_wrapper/Git_wrapper.mli
@@ -201,13 +201,20 @@ val project_url : ?cwd:Fpath.t -> unit -> string option
     TODO: should maybe raise an exn instead if not run from a git repo.
 *)
 
-val logs : ?cwd:Fpath.t -> ?since:float -> < Cap.exec > -> string list
-(** [logs ()] will run 'git log' in the current directory
-    and returns for each log a JSON string that fits the schema
-    defined in semgrep_output_v1.atd contribution type.
+type contribution = {
+  commit_hash : string;
+  (* datetime *)
+  commit_timestamp : string;
+  commit_author_name : string;
+  commit_author_email : string;
+}
+
+val logs : ?cwd:Fpath.t -> ?since:float -> < Cap.exec > -> contribution list
+(** [logs ()] will run 'git log' in the current directory and returns for each
+    log a contribution record.
     It returns an empty list if it found nothing relevant.
-    You can use the [since] parameter to restrict the logs to
-    the commits since the specified time.
+    You can use the [since] parameter to restrict the logs to the commits since
+    the specified time.
  *)
 
 type hash = Digestif.SHA1.t [@@deriving show, eq, ord, yojson]
diff --git a/libs/gitignore/Gitignores_cache.ml b/libs/gitignore/Gitignore_cache.ml
similarity index 100%
rename from libs/gitignore/Gitignores_cache.ml
rename to libs/gitignore/Gitignore_cache.ml
diff --git a/libs/gitignore/Gitignores_cache.mli b/libs/gitignore/Gitignore_cache.mli
similarity index 100%
rename from libs/gitignore/Gitignores_cache.mli
rename to libs/gitignore/Gitignore_cache.mli
diff --git a/libs/gitignore/Gitignore_filter.ml b/libs/gitignore/Gitignore_filter.ml
index 7b72cab97d4c..5ec936b2e942 100644
--- a/libs/gitignore/Gitignore_filter.ml
+++ b/libs/gitignore/Gitignore_filter.ml
@@ -10,7 +10,7 @@ let create ?gitignore_filenames ?(higher_priority_levels = [])
     project_root;
     higher_priority_levels;
     gitignore_file_cache =
-      Gitignores_cache.create ?gitignore_filenames ~project_root ();
+      Gitignore_cache.create ?gitignore_filenames ~project_root ();
     lower_priority_levels;
   }
 
@@ -64,7 +64,7 @@ let select_path opt_gitignore_file_cache sel_events levels relative_segments =
           match opt_gitignore_file_cache with
           | Some cache -> (
               (* load local gitignore file *)
-              match Gitignores_cache.load cache parent_path with
+              match Gitignore_cache.load cache parent_path with
               | Some additional_level -> levels @ [ additional_level ]
               | None -> levels)
           | None -> levels
diff --git a/libs/lib_parsing/Pos.ml b/libs/lib_parsing/Pos.ml
index 5aaf085c6de1..061d28b2c3cd 100644
--- a/libs/lib_parsing/Pos.ml
+++ b/libs/lib_parsing/Pos.ml
@@ -80,7 +80,7 @@ let make ?(line = -1) ?(column = -1) file bytepos =
 (* Helpers *)
 (*****************************************************************************)
 
-let first_pos_of_file file = make ~line:1 ~column:0 (Fpath.v file) 0
+let first_pos_of_file file = make ~line:1 ~column:0 file 0
 
 (* for error reporting *)
 let string_of_pos { file; line; column; _ } = spf "%s:%d:%d" !!file line column
diff --git a/libs/lib_parsing/Pos.mli b/libs/lib_parsing/Pos.mli
index 52e55c3735bd..2a70dfaf5f83 100644
--- a/libs/lib_parsing/Pos.mli
+++ b/libs/lib_parsing/Pos.mli
@@ -24,7 +24,7 @@ type linecol = { l : int; c : int } [@@deriving show, eq]
 (* Helpers *)
 (*****************************************************************************)
 
-val first_pos_of_file : string (* filename *) -> t
+val first_pos_of_file : Fpath.t -> t
 
 (* for error reporting *)
 val string_of_pos : t -> string
diff --git a/libs/lib_parsing/Tok.mli b/libs/lib_parsing/Tok.mli
index 049d067706ec..6eae08cdc9ca 100644
--- a/libs/lib_parsing/Tok.mli
+++ b/libs/lib_parsing/Tok.mli
@@ -91,10 +91,10 @@ val tok_of_loc : location -> t
 val make : str:string -> file:Fpath.t -> bytepos:int -> t
 
 (* the token will be empty, but its pos will be the beginning of the file *)
-val first_tok_of_file : string (* filename *) -> t
+val first_tok_of_file : Fpath.t -> t
 
 (* similar, the location will be empty *)
-val first_loc_of_file : string (* filename *) -> location
+val first_loc_of_file : Fpath.t -> location
 
 (* used mainly by tree-sitter based parsers in semgrep.
  * [combine_toks t1 ts] will return a token where t1::ts
diff --git a/libs/parallelism/Parmap_.ml b/libs/parallelism/Parmap_.ml
index a46c6ac3f46e..ebb57b945505 100644
--- a/libs/parallelism/Parmap_.ml
+++ b/libs/parallelism/Parmap_.ml
@@ -40,11 +40,32 @@ let () =
           Some (Printf.sprintf "Parmap_unhandled_children([%s])" tuples)
       | _ -> None)
 
+(* alt: use Logs src in Parmap code? *)
 let debugging = Parmap.debugging
 
 let default_exception_handler (_x : 'a) (e : Exception.t) =
   Exception.to_string e
 
+let wrap_result f ~exception_handler x =
+  try Ok (f x) with
+  | exn ->
+      let e = Exception.catch exn in
+      (* From marshal.mli in the OCaml stdlib:
+       *  "Values of extensible variant types, for example exceptions (of
+       *  extensible type [exn]), returned by the unmarshaller should not be
+       *  pattern-matched over through [match ... with] or [try ... with],
+       *  because unmarshalling does not preserve the information required for
+       *  matching their constructors. Structural equalities with other
+       *  extensible variant values does not work either.  Most other uses such
+       *  as Printexc.to_string, will still work as expected."
+       *)
+      (* Because of this we cannot just catch the exception here and return
+         it, as then it won't be super usable. Instead we ask the user of the
+         library to handle it in the process, since then they can pattern
+         match on it. They can choose to convert it to a string, a different
+         datatype etc. *)
+      Error (exception_handler x e)
+
 let parmap _caps ?init ?finalize ~ncores ~chunksize ~exception_handler f xs =
   (* Why do this? The nanny state doesn't trust you to to use parmap AND catch
      all your exceptions. And if you don't catch all your exceptions and one
@@ -53,26 +74,7 @@ let parmap _caps ?init ?finalize ~ncores ~chunksize ~exception_handler f xs =
      of your [f] is almost certainly not [exn]. So what we do here is catch all
      exceptions and return a result instead, meaning parmap will ALWAYS receive
      the correct marshaled data type *)
-  let f' x =
-    try Ok (f x) with
-    | exn ->
-        let e = Exception.catch exn in
-        (* From marshal.mli in the OCaml stdlib:
-         *  "Values of extensible variant types, for example exceptions (of
-         *  extensible type [exn]), returned by the unmarshaller should not be
-         *  pattern-matched over through [match ... with] or [try ... with],
-         *  because unmarshalling does not preserve the information required for
-         *  matching their constructors. Structural equalities with other
-         *  extensible variant values does not work either.  Most other uses such
-         *  as Printexc.to_string, will still work as expected."
-         *)
-        (* Because of this we cannot just catch the exception here and return
-           it, as then it won't be super usable. Instead we ask the user of the
-           library to handle it in the process, since then they can pattern
-           match on it. They can choose to convert it to a string, a different
-           datatype etc. *)
-        Error (exception_handler x e)
-  in
+  let f' x = wrap_result f ~exception_handler x in
   let finally () =
     (* Parmap doesn't handle its child processes that well, and so if they exit
        abnormally (e.g. segfault) then we have to clean up its mess.
diff --git a/libs/parallelism/Parmap_.mli b/libs/parallelism/Parmap_.mli
index 6423586e6da2..fa75e54a7dee 100644
--- a/libs/parallelism/Parmap_.mli
+++ b/libs/parallelism/Parmap_.mli
@@ -1,4 +1,4 @@
-(* Capability aware wrapper over Parmap (and a few helpers) *)
+(* Capability and exception aware wrapper over Parmap (and a few helpers) *)
 
 exception Parmap_unhandled_children of (string * int) list
 (** List of children spawned by [Parmap] that were not awaited correctly by
@@ -71,6 +71,15 @@ val parmap :
     ]}
 *)
 
+(* internal helper useful outside if you want to reproduce with
+ * List.map the semantic of Parmap_.parmap()
+ *)
+val wrap_result :
+  ('a -> 'b) ->
+  exception_handler:('a -> Exception.t -> 'err) ->
+  'a ->
+  ('b, 'err) result
+
 val disable_core_pinning : unit -> unit
 
 (* return the number of cpus *)
diff --git a/libs/paths/Project.ml b/libs/paths/Project.ml
index 8648cfc12ec2..45f3a5eea799 100644
--- a/libs/paths/Project.ml
+++ b/libs/paths/Project.ml
@@ -42,26 +42,58 @@ let get_project_root_for_nonproject_file (path : Fpath.t) : Rfpath.t =
   if Fpath.is_rel path then Rfpath.getcwd ()
   else Rfpath.of_fpath_exn (Fpath.parent path)
 
+(*
+   A git project created with 'git clone' or 'git init' has a '.git/' folder
+   but if worktrees are created, their root only has a '.git' file that
+   contains a reference to the main worktree that has the '.git/' folder
+   with all the data to manage the local repo and its worktrees.
+
+   For example, my current worktree contains this:
+
+     ~/spro2 $ cat .git
+     gitdir: /home/martin/spro/.git/worktrees/spro2
+*)
+let is_git_project_root dir =
+  let git_folder_or_file = dir / ".git" in
+  if Sys.file_exists !!git_folder_or_file then
+    (* TODO: check that the contents of the '.git' look legit? *)
+    Some (Git_project, dir)
+  else None
+
+(*
+   Check for the presence of a special folder at the project root
+   such as '.hg'. This is imperfect and could be improved if needed.
+*)
+let is_project_with_special_dir kind special_dir_name dir =
+  let special_dir = !!(dir / special_dir_name) in
+  if Sys.file_exists special_dir && Sys.is_directory special_dir then
+    Some (kind, dir)
+  else None
+
+let is_mercurial_project_root =
+  is_project_with_special_dir Mercurial_project ".hg"
+
+let is_darcs_project_root = is_project_with_special_dir Darcs_project "_darcs"
+
+let is_subversion_project_root =
+  is_project_with_special_dir Subversion_project ".svn"
+
 (* alt: use 'git rev-parse --show-toplevel' but this would be git specific
  * and would require to have an external 'git' program.
  *)
 let get_project_root_of_fpath_opt (path : Fpath.t) : (kind * Fpath.t) option =
-  let candidates =
+  let candidates : (Fpath.t -> (kind * Fpath.t) option) list =
     [
-      (".git", Git_project);
-      (".hg", Mercurial_project);
-      ("_darcs", Darcs_project);
-      (".svn", Subversion_project);
+      is_git_project_root;
+      is_mercurial_project_root;
+      is_darcs_project_root;
+      is_subversion_project_root;
     ]
   in
   let rec aux dir =
     let res =
       candidates
-      |> List.find_map (fun (subdir, kind) ->
-             let final = !!(dir / subdir) in
-             if Sys.file_exists final && Sys.is_directory final then
-               Some (kind, dir)
-             else None)
+      |> List.find_map (fun is_xxx_project_root -> is_xxx_project_root dir)
     in
     match res with
     | Some x -> Some x
diff --git a/libs/tracing/Tracing.mli b/libs/tracing/Tracing.mli
index a8d6968f07b5..5e4afb855c50 100644
--- a/libs/tracing/Tracing.mli
+++ b/libs/tracing/Tracing.mli
@@ -23,6 +23,10 @@ type span = Trace_core.span [@@deriving show]
 
 type config = {
   endpoint : Uri.t;
+  (* Telemetry software like datadog and opentelemetry will organize traces by
+     the environment they come from (e.g. development, staging, production). env
+     here sets that metadata *)
+  env : string option;
   (* To add data to our opentelemetry top span, so easier to filter *)
   top_level_span : span option;
 }
@@ -91,13 +95,24 @@ val trace_data_only :
 (* Entry points for setting up tracing *)
 (*****************************************************************************)
 
-val configure_tracing : string -> Uri.t -> unit
-(** [configure_tracing service_name tracing_endpoint] Before instrumenting
-    anything, configure some settings. This should only be run once in a
-    program, because it creates a backend with threads, HTTP connections, etc.
-    when called. NOTE: this will set the active trace endpoint to whatever is
-    passed. This endpoint will be used when restarting tracing via
-    [restart_tracing] *)
+val configure_tracing :
+  ?attrs:(string * user_data) list ->
+  ?env:string ->
+  ?version:string ->
+  string ->
+  Uri.t ->
+  unit
+(** [configure_tracing ~env:"prod" ~version:"v1.0.0" service_name
+    tracing_endpoint] Before instrumenting anything, configure some settings.
+    This should only be run once in a program, because it creates a backend with
+    threads, HTTP connections, etc. when called. [~env] is the environment (e.g.
+    production, staging, development). [~version] is the version of the service.
+    [service_name] is the name of the service. [~attrs] can be used to set
+    additional global attributes, which are tags that will be applied to all
+    outgoing traces/metrics/logs etc.
+
+    NOTE: this will set the active trace endpoint to whatever is passed. This
+    endpoint will be used when restarting tracing via [restart_tracing] *)
 
 val stop_tracing : unit -> unit
 (** [stop_tracing ()] explicitly shuts down the Otel collector. If tracing has
diff --git a/libs/tracing/js/Tracing.ml b/libs/tracing/js/Tracing.ml
index 84bef905b70c..b85c55c0e346 100644
--- a/libs/tracing/js/Tracing.ml
+++ b/libs/tracing/js/Tracing.ml
@@ -29,6 +29,8 @@ type user_data = Trace_core.user_data
 
 type config = {
   endpoint : Uri.t;
+  (* set the deployment environment name for organization reasons in APMs *)
+  env : string option;
   (* To add data to our opentelemetry top span, so easier to filter *)
   top_level_span : span option;
 }
@@ -86,7 +88,13 @@ let trace_data_only ?(level = Info) ~__FUNCTION__ ~__FILE__ ~__LINE__ _name
 
 let stop_tracing () = ()
 let restart_tracing () = ()
-let configure_tracing (_service_name : string) (_endpoint : Uri.t) = ()
+
+let configure_tracing ?(attrs = []) ?(env = "") ?(version = "")
+    (_service_name : string) (_endpoint : Uri.t) =
+  ignore attrs;
+  ignore env;
+  ignore version;
+  ()
 
 let with_tracing (_fname : string)
     (_data : (string * Trace_core.user_data) list) f =
diff --git a/libs/tracing/unix/Tracing.ml b/libs/tracing/unix/Tracing.ml
index 306704a77a5f..ebfcfb0021b0 100644
--- a/libs/tracing/unix/Tracing.ml
+++ b/libs/tracing/unix/Tracing.ml
@@ -76,6 +76,7 @@ type user_data = Trace_core.user_data
 
 type config = {
   endpoint : Uri.t;
+  env : string option;
   (* To add data to our opentelemetry top span, so easier to filter *)
   top_level_span : span option;
 }
@@ -95,6 +96,43 @@ let trace_level_var = "SEMGREP_TRACE_LEVEL"
 let parent_span_id_var = "SEMGREP_TRACE_PARENT_SPAN_ID"
 let parent_trace_id_var = "SEMGREP_TRACE_PARENT_TRACE_ID"
 
+(*****************************************************************************)
+(* Helpers *)
+(*****************************************************************************)
+
+let ( let@ ) = ( @@ )
+
+(* Needed so we can reset span id's randomness on tracing restart *)
+(* See restart_tracing for more detail *)
+let mk_rand_bytes_8 rand_ () : bytes =
+  let@ () = Otel.Lock.with_lock in
+  let b = Bytes.create 8 in
+  for i = 0 to 1 do
+    let r = Random.State.bits rand_ in
+    (* 30 bits, of which we use 24 *)
+    Bytes.set b (i * 3) (Char.chr (r land 0xff));
+    Bytes.set b ((i * 3) + 1) (Char.chr ((r lsr 8) land 0xff));
+    Bytes.set b ((i * 3) + 2) (Char.chr ((r lsr 16) land 0xff))
+  done;
+  let r = Random.State.bits rand_ in
+  Bytes.set b 6 (Char.chr (r land 0xff));
+  Bytes.set b 7 (Char.chr ((r lsr 8) land 0xff));
+  b
+
+let mk_rand_bytes_16 rand_ () : bytes =
+  let@ () = Otel.Lock.with_lock in
+  let b = Bytes.create 16 in
+  for i = 0 to 4 do
+    let r = Random.State.bits rand_ in
+    (* 30 bits, of which we use 24 *)
+    Bytes.set b (i * 3) (Char.chr (r land 0xff));
+    Bytes.set b ((i * 3) + 1) (Char.chr ((r lsr 8) land 0xff));
+    Bytes.set b ((i * 3) + 2) (Char.chr ((r lsr 16) land 0xff))
+  done;
+  let r = Random.State.bits rand_ in
+  Bytes.set b 15 (Char.chr (r land 0xff));
+  (* last byte *)
+  b
 (*****************************************************************************)
 (* Levels *)
 (*****************************************************************************)
@@ -145,18 +183,30 @@ let add_yojson_to_span sp yojson =
 (* Span/Event entrypoints *)
 (*****************************************************************************)
 
-let trace_exn sp ?(escaped = false) exn =
+let trace_exn sp exn =
   let e = Exception.catch exn in
+  let exn_type = Printexc.to_string_default exn in
   let exn_msg = Printexc.to_string exn in
   let exn_stacktrace =
     e |> Exception.get_trace |> Printexc.raw_backtrace_to_string
   in
+
+  (* Datadog friendly attrs for the span
+     See:
+     https://docs.datadoghq.com/tracing/error_tracking/#use-span-tags-to-track-error-spans
+  *)
+
+  (* Note these are not what the otel spec expects, but the ocaml otel libary
+     will do this in a future version:
+     https://github.com/imandra-ai/ocaml-opentelemetry/pull/63
+  *)
   let attrs =
-    (* See: https://opentelemetry.io/docs/specs/semconv/exceptions/exceptions-spans/#stacktrace-representation*)
     [
-      ("exception.escaped", `Bool escaped);
-      ("exception.message", `String exn_msg);
-      ("exception.stacktrace", `String exn_stacktrace);
+      ("error.message", `String exn_msg);
+      ("error.stack", `String exn_stacktrace);
+      ("error.type", `String exn_type);
+      (* Forces datadog to actually track an error *)
+      ("track_error", `Bool true);
     ]
   in
   add_data_to_span sp attrs
@@ -172,13 +222,11 @@ let with_span ?(level = Info) ?__FUNCTION__ ~__FILE__ ~__LINE__ ?data name f =
   let level = level_to_trace_level level in
   Trace_core.with_span ~level ?__FUNCTION__ ~__FILE__ ~__LINE__ ?data name
     (fun sp ->
-      (* TODO: upstream the error catching functionality below:
-               https://github.com/imandra-ai/ocaml-opentelemetry/issues/71 *)
-      (* TODO: set span status to Error so we can get groupings in datadog/sentry *)
+      (* TODO: Otel library does this for us in the next version(current is 0.10) *)
       try f sp with
       | exn ->
           let e = Exception.catch exn in
-          trace_exn sp ~escaped:true exn;
+          trace_exn sp exn;
           mark_span_error sp;
           Trace_core.exit_span sp;
           Exception.reraise e)
@@ -246,8 +294,17 @@ let setup_otel trace_endpoint =
   Opentelemetry_trace.setup_with_otel_backend otel_backend
 
 (* Set according to README of https://github.com/imandra-ai/ocaml-opentelemetry/ *)
-let configure_tracing service_name trace_endpoint =
+let configure_tracing ?(attrs : (string * user_data) list = []) ?(env = "local")
+    ?version service_name trace_endpoint =
   Otel.Globals.service_name := service_name;
+  Otel.Globals.default_span_kind := Otel.Span.Span_kind_internal;
+  version
+  |> Option.iter (fun version ->
+         Otel.Globals.add_global_attribute "service.version" (`String version));
+  Otel.Globals.add_global_attribute "deployment.environment.name" (`String env);
+  List.iter
+    (fun (key, value) -> Otel.Globals.add_global_attribute key value)
+    attrs;
   Log.info (fun m -> m "Setting up tracing with service name %s" service_name);
   Otel.GC_metrics.basic_setup ();
   Ambient_context.set_storage_provider (Ambient_context_lwt.storage ());
@@ -274,6 +331,13 @@ let stop_tracing () =
          Backend.cleanup ())
 
 let restart_tracing () =
+  (* We must re-initialize the randomness on restart since this usually happens
+     after a parmap fork. If we don't do this then all parmap forks will have
+     the same randomness and use duplicate span ids! This behavior is fine in
+     jaeger but duplicates don't show up in datadog *)
+  let new_random_state = Random.State.make_self_init () in
+  Otel.Rand_bytes.rand_bytes_8 := mk_rand_bytes_8 new_random_state;
+  Otel.Rand_bytes.rand_bytes_16 := mk_rand_bytes_16 new_random_state;
   !active_endpoint
   |> Option.iter (fun endpoint ->
          Log.info (fun m -> m "Restarting tracing");
diff --git a/src/analyzing/AST_to_IL.ml b/src/analyzing/AST_to_IL.ml
index 79c3df673f15..9f6542445b00 100644
--- a/src/analyzing/AST_to_IL.ml
+++ b/src/analyzing/AST_to_IL.ml
@@ -38,6 +38,7 @@ let locate ?tok s =
   | Some loc -> spf "%s: %s" loc s
   | None -> s
 
+let log_debug ?tok msg = Log.debug (fun m -> m "%s" (locate ?tok msg))
 let log_warning ?tok msg = Log.warn (fun m -> m "%s" (locate ?tok msg))
 let log_error ?tok msg = Log.err (fun m -> m "%s" (locate ?tok msg))
 
@@ -137,7 +138,7 @@ let var_of_id_info id id_info =
     | None ->
         let id_str, id_tok = id in
         let msg = spf "the ident '%s' is not resolved" id_str in
-        log_warning ~tok:id_tok msg;
+        log_debug ~tok:id_tok msg;
         G.SId.unsafe_default
   in
   { ident = id; sid; id_info }
@@ -897,6 +898,9 @@ and expr_aux env ?(void = false) g_expr =
       let e = expr env e in
       mk_e (Cast (typ, e)) eorig
   | G.Alias (_alias, e) -> expr env e
+  | G.LocalImportAll (_module, _tk, e) ->
+      (* TODO: what can we do with _module? *)
+      expr env e
   | G.Ellipsis _
   | G.TypedMetavar (_, _, _)
   | G.DisjExpr (_, _)
diff --git a/src/analyzing/CFG_build.ml b/src/analyzing/CFG_build.ml
index 3ea083bbea94..84a957a7349e 100644
--- a/src/analyzing/CFG_build.ml
+++ b/src/analyzing/CFG_build.ml
@@ -1,7 +1,7 @@
-(* Yoann Padioleau
+(* Yoann Padioleau, Iago Abal
  *
  * Copyright (C) 2009, 2010, 2011 Facebook
- * Copyright (C) 2020 r2c
+ * Copyright (C) 2020-2024 Semgrep
  *
  * This library is free software; you can redistribute it and/or
  * modify it under the terms of the GNU Lesser General Public License
@@ -48,9 +48,10 @@ type label_key = string * G.sid
  * No need to return a new state.
  *)
 type state = {
-  opt_tok : Tok.t option;
   (* An optional token to point to the function/entity for which we are
    * constructing a CFG. *)
+  opt_tok : Tok.t option;
+  (* The graph under construction. *)
   g : (F.node, F.edge) Ograph_extended.ograph_mutable;
   (* When there is a 'return' we need to know the exit node to link to *)
   exiti : F.nodei;
@@ -67,20 +68,11 @@ type state = {
    * and there is no significant benefit of having these extra arcs.
    *)
   throw_destination : F.nodei option;
-  (* Lambdas are always assigned to a variable in IL, this table records the
-   * name-to-lambda mapping. Whenever a lambda is fetched, we look it up here
-   * and we generate the lambda's CFG right at the use site.
-   *
-   * Why at the use site? So that the fixpoint function will visit them in the
-   * right order. Then we can propagate taint from e.g. an object receiving
-   * a method call, to a lambda being passed to that method. Previously, we
-   * always inserted the lambdas CFGs preceding their use, so taint propagation
-   * could not happen.
-   *)
-  lambdas : (name, IL.function_definition) Hashtbl.t;
-  (* If a lambda is never used, we just insert its CFG at the end of
-     its parent function. *)
-  unused_lambdas : (name, unit) Hashtbl.t;
+  (* The CFG of each lambda is kept here separately; it is not part of the
+   * CFG of its enclosing function. Note that an 'IL.fun_cfg' gives you the
+   * "main" CFG as well as the lambdas' CFGs, and each analysis chooses how to
+   * handle the lambdas. *)
+  lambdas_cfgs : IL.lambdas_cfgs ref;
 }
 
 (*****************************************************************************)
@@ -124,15 +116,6 @@ let resolve_gotos state =
          | Some dsti -> state.g |> add_arc (srci, dsti));
   state.gotos := []
 
-let lval_is_lambda state l =
-  match l with
-  | { base = Var name; rev_offset = [] } ->
-      let* fdef = Hashtbl.find_opt state.lambdas name in
-      Some (name, fdef)
-  | { base = Var _ | VarSpecial _ | Mem _; rev_offset = _ } ->
-      (* Lambdas are only assigned to plain variables without any offset. *)
-      None
-
 (*****************************************************************************)
 (* Algorithm *)
 (*****************************************************************************)
@@ -180,10 +163,10 @@ let rec cfg_stmt : state -> F.nodei option -> stmt -> cfg_stmt_result =
       let new_ = F.NInstr x in
       let newi = state.g#add_node (IL.mk_node new_) in
       state.g |> add_arc_from_opt (previ, newi);
-      let lasti, throws =
+      let throws =
         match x.i with
         | New _
-        | Call _ -> (
+        | Call _ ->
             (* If we are inside a try-catch, we consider the possibility of this call
                * raising an exception, then we add a jump to catch-blocks. This could
                * lead to some false positives when running taint rules (since it's a
@@ -192,21 +175,15 @@ let rec cfg_stmt : state -> F.nodei option -> stmt -> cfg_stmt_result =
                * Ideally we should have a preceeding analysis that infers which calls
                * may (or may not) raise exceptions. *)
             state.g |> add_arc_opt_to_opt (Some newi, state.throw_destination);
-            match build_cfg_for_lambdas_in state (Some newi) new_ with
-            | Some lasti -> (lasti, true)
-            | None -> (newi, true))
+            true
         | AssignAnon ({ base = Var name; rev_offset = [] }, Lambda fdef) ->
-            (* Just in case the lambda CFG needs be inserted here later on (if the
-             * lambda is never dereferenced) then we have to insert a JOIN node here,
-             * see cfg_lambda. *)
-            let lasti = state.g#add_node (IL.mk_node F.OtherJoin) in
-            state.g |> add_arc (newi, lasti);
-            Hashtbl.add state.lambdas name fdef;
-            Hashtbl.add state.unused_lambdas name ();
-            (lasti, false)
-        | __else__ -> (newi, false)
+            let lambda_cfg = cfg_of_fdef fdef in
+            state.lambdas_cfgs :=
+              IL.NameMap.add name lambda_cfg !(state.lambdas_cfgs);
+            false
+        | __else__ -> false
       in
-      CfgFirstLast (newi, Some lasti, throws)
+      CfgFirstLast (newi, Some newi, throws)
   | If (tok, e, st1, st2) -> (
       (* previ -> newi --->  newfakethen -> ... -> finalthen --> lasti -> <rest>
        *                |                                     |
@@ -263,13 +240,8 @@ let rec cfg_stmt : state -> F.nodei option -> stmt -> cfg_stmt_result =
       let new_ = F.NReturn (tok, e) in
       let newi = state.g#add_node (IL.mk_node new_) in
       state.g |> add_arc_from_opt (previ, newi);
-      let lasti =
-        match build_cfg_for_lambdas_in state (Some newi) new_ with
-        | Some lasti -> lasti
-        | None -> newi
-      in
-      state.g |> add_arc (lasti, state.exiti);
-      CfgFirstLast (lasti, None, false)
+      state.g |> add_arc (newi, state.exiti);
+      CfgFirstLast (newi, None, false)
   | Try (try_st, catches, else_st, finally_st) ->
       (* previ ->
        * newi ->
@@ -370,62 +342,6 @@ let rec cfg_stmt : state -> F.nodei option -> stmt -> cfg_stmt_result =
       CfgFirstLast (newi, Some newi, false)
   | FixmeStmt _ -> cfg_todo state previ stmt
 
-and cfg_lambda state previ joini (fdef : IL.function_definition) =
-  (* Lambdas are treated as statement blocks, the CFG does NOT capture the actual
-   * flow of data that goes into the lambda through its parameters, and back
-   * into the surrounding definition through its `return' statement. We won't
-   * do inter-procedural analysis here, it is a DeepSemgrep thing.
-   *
-   * previ -> NLambda params -> ... (lambda body) -> finallambda -> lasti
-   *
-   * alt: We could inline lambdas perhaps?
-   *
-   * coupling: if we ever decide to capture the flow of data into the lambda
-   * here, the handling of the NLambda case in Dataflow_when has to be updated.
-   *)
-  let newi = state.g#add_node (IL.mk_node (NLambda fdef.fparams)) in
-  state.g |> add_arc_from_opt (previ, newi);
-  let finallambda, _ignore_throws_in_lambda_ =
-    cfg_stmt_list
-      { state with throw_destination = None }
-      (Some newi) fdef.IL.fbody
-  in
-  state.g |> add_arc_from_opt (finallambda, joini)
-
-and build_cfg_for_lambdas_in state previ n =
-  (* We look for all lambdas being fetched within node `n`, and insert their CFGs
-   * right there.
-   *
-   * THINK: Where do we need to call this? Clearly we need to check `NInstr` nodes
-   *   since lambdas will typically be dereferenced in `Call` instructions, either
-   *   as the function being called, or as arguments to a higher-order function. *)
-  let lambda_names, lambda_fdefs =
-    IL_helpers.rlvals_of_node n
-    |> List_.filter_map (lval_is_lambda state)
-    |> List_.split
-  in
-  if lambda_fdefs <> [] then (
-    (* We translate the set of lambdas used in the node as like an IF-ELSEIF-ELSE,
-     * block, with a fall-through case. This is meant to reduce FPs, but we should
-     * revisit it if we are proven wrong. Note that we insert the lambda's CFG at
-     * the site where they occur in the code, but they are not necessarily being
-     * run there (often they are arguments to high-order functions). If the node
-     * has multiple lambdas, it will typically be a call to a function that takes
-     * multiple lambdas as arguments.
-     *
-     * previ ->  (lambda 1)     /-> lasti
-     *       \-> ...         ->/
-     *       \-> (lambda N) ->/
-     *       \_______________/
-     *)
-    let lasti = state.g#add_node (IL.mk_node F.OtherJoin) in
-    state.g |> add_arc_from_opt (previ, lasti);
-    lambda_fdefs |> List.iter (fun fdef -> cfg_lambda state previ lasti fdef);
-    lambda_names
-    |> List.iter (fun name -> Hashtbl.remove state.unused_lambdas name);
-    Some lasti)
-  else None
-
 and cfg_todo state previ stmt =
   let newi = state.g#add_node (IL.mk_node (F.NTodo stmt)) in
   state.g |> add_arc_from_opt (previ, newi);
@@ -468,24 +384,11 @@ and cfg_stmt_list state previ xs =
       (Some dummyi, may_throw)
   | [] -> (lasti_opt, may_throw)
 
-let build_cfg_of_unused_lambdas state previ nexti =
-  state.unused_lambdas
-  |> Hashtbl.iter (fun name _ ->
-         match Hashtbl.find_opt state.lambdas name with
-         | None ->
-             let tok = snd name.ident in
-             Log.warn (fun m ->
-                 m ~tags "Cannot find the definition of a lambda (%s)"
-                   (Tok.stringpos_of_tok tok));
-             ()
-         | Some fdef -> cfg_lambda state previ nexti fdef);
-  Hashtbl.clear state.unused_lambdas
-
 (*****************************************************************************)
 (* Marking nodes *)
 (*****************************************************************************)
 
-let mark_at_exit_nodes cfg =
+and mark_at_exit_nodes cfg =
   let rec loop nodei =
     let node = cfg.CFG.graph#nodes#find nodei in
     match node.n with
@@ -493,14 +396,12 @@ let mark_at_exit_nodes cfg =
     | Exit
     | NOther (Noop _)
     | NGoto _
-    | Join
-    | OtherJoin ->
+    | Join ->
         CFG.predecessors cfg nodei |> List.iter (fun (predi, _) -> loop predi)
     (* These can be at-exit nodes. *)
     | NInstr _
     | NReturn _
     | NThrow _
-    | NLambda _
     | NTodo _ ->
         node.at_exit <- true
     (* Whereas these cannot. *)
@@ -517,8 +418,7 @@ let mark_at_exit_nodes cfg =
 (* Main entry point *)
 (*****************************************************************************)
 
-let (cfg_of_stmts : ?tok:Tok.t -> stmt list -> F.cfg) =
- fun ?tok xs ->
+and cfg_of_stmts ?tok (xs : stmt list) : IL.cfg * IL.lambdas_cfgs =
   (* yes, I sometimes use objects, and even mutable objects in OCaml ... *)
   let g = new Ograph_extended.ograph_mutable in
 
@@ -535,61 +435,24 @@ let (cfg_of_stmts : ?tok:Tok.t -> stmt list -> F.cfg) =
       labels = Hashtbl.create 10;
       gotos = ref [];
       throw_destination = None;
-      lambdas = Hashtbl.create 10;
-      unused_lambdas = Hashtbl.create 10;
+      lambdas_cfgs = ref NameMap.empty;
     }
   in
   let last_node_opt, _ignore_may_throw_ = cfg_stmt_list state (Some newi) xs in
   (* Must wait until all nodes have been labeled before resolving gotos. *)
   resolve_gotos state;
-  (* Previously, we used to insert the CFGs of unused lambdas at the
-     declaration site. However, this approach triggered some false
-     positives. For example, consider the following code:
-     ```
-     void incorrect(int *p) {
-       auto f1 = [&p]() {
-         source(p);
-       };
-       auto f2 = [&p]() {
-         sink(p);
-       };
-     }
-     ```
-     In this code, there's no actual control flow between the source
-     and sink, and the lambdas are never even called. But when we
-     inserted their CFGs at the declaration site, it incorrectly
-     indicated a taint finding. To prevent these types of false
-     positives while still scanning the body of unused lambdas, we now
-     insert their CFGs in parallel at the end of their parent
-     function, right after all other statements and just before the
-     end node. *)
-  build_cfg_of_unused_lambdas state last_node_opt exiti;
   (* maybe the body does not contain a single 'return', so by default
    * connect last stmt to the exit node
    *)
   g |> add_arc_from_opt (last_node_opt, exiti);
-  CFG.make g enteri exiti
-
-let cfg_of_fdef lang ?tok fdef =
-  let cfg_of_fdef () =
-    let fcfg = cfg_of_stmts ?tok fdef.fbody in
-    mark_at_exit_nodes fcfg;
-    IL.{ fparams = fdef.fparams; fcfg }
-  in
-
-  if Implicit_return.lang_supports_implicit_return lang then (
-    (* We need to build the CFG here first because the analysis
-     * visits the CFG to determine returning nodes.
-     *)
-    (* TODO: Could we instead just transform the CFG directly ? *)
-    Implicit_return.mark_implicit_return_nodes (cfg_of_stmts fdef.fbody);
+  let cfg = CFG.make g enteri exiti in
+  (cfg, !(state.lambdas_cfgs))
 
-    (* Rebuild CFG after marking the return nodes, so that all
-     * implicit returns become explicit.
-     *)
-    cfg_of_fdef ())
-  else cfg_of_fdef ()
+and cfg_of_fdef fdef =
+  let cfg, lambdas = cfg_of_stmts ~tok:(snd fdef.fkind) fdef.fbody in
+  mark_at_exit_nodes cfg;
+  IL.{ params = fdef.fparams; cfg; lambdas }
 
 let cfg_of_gfdef lang ?ctx fdef =
   let fdef_il = AST_to_IL.function_definition lang ?ctx fdef in
-  cfg_of_fdef lang ~tok:(snd fdef.fkind) fdef_il
+  cfg_of_fdef fdef_il
diff --git a/src/analyzing/CFG_build.mli b/src/analyzing/CFG_build.mli
index 01484fbfc773..4305da9afd73 100644
--- a/src/analyzing/CFG_build.mli
+++ b/src/analyzing/CFG_build.mli
@@ -1,20 +1,13 @@
-(* Compute the control flow graph of a sequence of statements.
- * This sequence of statements doesn't necessarily have to be
- * a function body, so no implicit return analysis is done here.
+val cfg_of_stmts : ?tok:Tok.t -> IL.stmt list -> IL.cfg * IL.lambdas_cfgs
+(** Compute the control flow graph of a sequence of statements.
  *
- * This is useful in cases such as handling top-level functions
- * which aren't real functions.
+ * This is useful in cases such as handling top-level instructions that
+ * do not constitute actual function definitions.
  *)
-val cfg_of_stmts : ?tok:Tok.t -> IL.stmt list -> IL.cfg
 
-val cfg_of_fdef :
-  Language.t -> ?tok:Tok.t -> IL.function_definition -> IL.fdef_cfg
-(** Compute the control flow graph of a function definition.
- * Implicit return analysis is applied and for languages that support
- * implicit return, the returning expressions will be converted to
- * explicit return nodes in the CFG.
- *)
+val cfg_of_fdef : IL.function_definition -> IL.fun_cfg
+(** Compute the control flow graph of an IL function definition. *)
 
 val cfg_of_gfdef :
-  Lang.t -> ?ctx:AST_to_IL.ctx -> AST_generic.function_definition -> IL.fdef_cfg
+  Lang.t -> ?ctx:AST_to_IL.ctx -> AST_generic.function_definition -> IL.fun_cfg
 (** Same as 'cfg_of_fdef' but takes a Generic function definition. *)
diff --git a/src/analyzing/Constant_propagation.ml b/src/analyzing/Constant_propagation.ml
index b7e9d5e388b7..072e0d15402a 100644
--- a/src/analyzing/Constant_propagation.ml
+++ b/src/analyzing/Constant_propagation.ml
@@ -613,10 +613,10 @@ let propagate_basic lang prog =
   ()
 [@@profiling] [@@trace_trace]
 
-let propagate_dataflow_one_function lang fdef_cfg =
+let propagate_dataflow_one_function lang fun_cfg =
   (* Exposed to help DeepSemgrep *)
-  let mapping = Dataflow_svalue.fixpoint lang fdef_cfg in
-  Dataflow_svalue.update_svalue fdef_cfg.fcfg mapping
+  let mapping = Dataflow_svalue.fixpoint lang fun_cfg in
+  Dataflow_svalue.update_svalue fun_cfg.cfg mapping
 
 let propagate_dataflow lang ast =
   Log.debug (fun m -> m ~tags "Constant_propagation.propagate_dataflow program");
@@ -627,12 +627,12 @@ let propagate_dataflow lang ast =
         AST_to_IL.stmt lang (G.Block (Tok.unsafe_fake_bracket ast) |> G.s)
       in
       (* Top-level function. No need to use CFG_build.cfg_of_gfdef here. *)
-      let flow = CFG_build.cfg_of_stmts xs in
-      propagate_dataflow_one_function lang IL.{ fparams = []; fcfg = flow }
+      let cfg, lambdas = CFG_build.cfg_of_stmts xs in
+      propagate_dataflow_one_function lang IL.{ params = []; cfg; lambdas }
   | _ ->
       ast
       |> Visit_function_defs.visit (fun _ent fdef ->
-             let fdef_cfg = CFG_build.cfg_of_gfdef lang fdef in
+             let fun_cfg = CFG_build.cfg_of_gfdef lang fdef in
              (* when/ pattern when is not constant propagation but is related in the sense
               * that it also finds extra info by analyzing the cfg. we are reusing the cfg
               * created here to annotate facts for the pattern when feature.
@@ -641,8 +641,8 @@ let propagate_dataflow lang ast =
               *)
              (match !Dataflow_when.hook_annotate_facts with
              | None -> ()
-             | Some annotate_facts -> annotate_facts fdef_cfg.fcfg);
-             propagate_dataflow_one_function lang fdef_cfg);
+             | Some annotate_facts -> annotate_facts fun_cfg.cfg);
+             propagate_dataflow_one_function lang fun_cfg);
 
       (* We consider the top-level function the interior of a degenerate function,
          and simply run constant propagation on that.
@@ -652,6 +652,6 @@ let propagate_dataflow lang ast =
       *)
       let xs = AST_to_IL.stmt lang (G.stmt1 ast) in
       (* Top-level function. No need to use CFG_build.cfg_of_gfdef here. *)
-      let flow = CFG_build.cfg_of_stmts xs in
-      propagate_dataflow_one_function lang IL.{ fparams = []; fcfg = flow }
+      let cfg, lambdas = CFG_build.cfg_of_stmts xs in
+      propagate_dataflow_one_function lang IL.{ params = []; cfg; lambdas }
 [@@trace_trace]
diff --git a/src/analyzing/Constant_propagation.mli b/src/analyzing/Constant_propagation.mli
index da3f5ea3b4c9..42707950a33e 100644
--- a/src/analyzing/Constant_propagation.mli
+++ b/src/analyzing/Constant_propagation.mli
@@ -29,6 +29,6 @@ val propagate_basic : Lang.t -> AST_generic.program -> unit
  * in which functions are analyzed. Generally will perform better
  * if propagate_basic is called first *)
 val propagate_dataflow_one_function :
-  Lang.t -> IL.fdef_cfg (* function CFG *) -> unit
+  Lang.t -> IL.fun_cfg (* function CFG *) -> unit
 
 val propagate_dataflow : Lang.t -> AST_generic.program -> unit
diff --git a/src/analyzing/Dataflow_core.ml b/src/analyzing/Dataflow_core.ml
index 77c75dbc6048..bbcda9d064b5 100644
--- a/src/analyzing/Dataflow_core.ml
+++ b/src/analyzing/Dataflow_core.ml
@@ -137,15 +137,14 @@ module Make (F : Flow) = struct
 
   let fixpoint_worker ~timeout eq_env mapping trans flow succs workset =
     let t0 = Sys.time () in
-    let rec loop work =
-      if NodeiSet.is_empty work then mapping
+    let rec loop i work =
+      if NodeiSet.is_empty work then (mapping, `Ok)
       else
         (* 'Time_limit.set_timeout' cannot be nested and we want to make sure that
          * fixpoint computations run for a limited amount of time. *)
         let t1 = Sys.time () in
-        if t1 -. t0 >= timeout then (
-          Log.err (fun m -> m "fixpoint_worker timed out");
-          mapping)
+        if i > Limits_semgrep.dataflow_FIXPOINT_MIN_ITERS && t1 -. t0 >= timeout
+        then (mapping, `Timeout)
         else
           let ni = NodeiSet.choose work in
           let work' = NodeiSet.remove ni work in
@@ -157,9 +156,9 @@ module Make (F : Flow) = struct
               mapping.(ni) <- new_;
               NodeiSet.union work' (succs flow ni))
           in
-          loop work''
+          loop (i + 1) work''
     in
-    loop workset
+    loop 0 workset
 
   let forward_succs (f : F.flow) n =
     (f.graph#successors n)#fold
@@ -178,7 +177,7 @@ module Make (F : Flow) = struct
         trans:'env transfn ->
         flow:F.flow ->
         forward:bool ->
-        'env mapping) =
+        'env mapping * [ `Ok | `Timeout ]) =
    fun ~timeout ~eq_env ~init ~trans ~flow ~forward ->
     let succs = if forward then forward_succs else backward_succs in
     let work =
diff --git a/src/analyzing/Dataflow_core.mli b/src/analyzing/Dataflow_core.mli
index bf9226fe1a81..f5c141005995 100644
--- a/src/analyzing/Dataflow_core.mli
+++ b/src/analyzing/Dataflow_core.mli
@@ -48,7 +48,7 @@ module Make (F : Flow) : sig
     trans:'env transfn ->
     flow:F.flow ->
     forward:bool ->
-    'env mapping
+    'env mapping * [ `Ok | `Timeout ]
 
   val new_node_array : F.flow -> 'a -> 'a array
 
diff --git a/src/analyzing/Dataflow_svalue.ml b/src/analyzing/Dataflow_svalue.ml
index 73dbf93342fa..4c1c00b1539a 100644
--- a/src/analyzing/Dataflow_svalue.ml
+++ b/src/analyzing/Dataflow_svalue.ml
@@ -307,14 +307,16 @@ let transfer_of_assume (assume : bool) (cond : IL.exp_kind)
   | None -> inp
   | Some hook -> hook assume cond inp
 
-let transfer :
+let rec transfer :
     lang:Lang.t ->
     enter_env:G.svalue Var_env.t ->
-    flow:F.cfg ->
+    fun_cfg:F.fun_cfg ->
     G.svalue Var_env.transfn =
- fun ~lang ~enter_env ~flow
+ fun ~lang ~enter_env ~fun_cfg
      (* the transfer function to update the mapping at node index ni *)
        mapping ni ->
+  let flow = fun_cfg.cfg in
+
   let node = flow.graph#nodes#assoc ni in
 
   let inp' = input_env ~enter_env ~flow mapping ni in
@@ -324,11 +326,9 @@ let transfer :
     | Enter
     | Exit
     | Join
-    | OtherJoin
     | NCond _
     | NGoto _
     | NReturn _
-    | NLambda _
     | NThrow _
     | NOther _
     | NTodo _ ->
@@ -403,23 +403,74 @@ let transfer :
             | None -> inp'
             | Some lvar -> VarMap.remove (IL.str_of_name lvar) inp'))
   in
-
+  let out' = do_lambdas lang fun_cfg.lambdas out' node in
   { D.in_env = inp'; out_env = out' }
 
+and do_lambdas lang lambdas in_env node =
+  (* In svalue-analysis we only need to visit lambdas at definition site,
+   * we simply propagate svalues into the lambda's body, but whatever
+   * happens inside the lambda is not visible outside it. *)
+  match node.F.n with
+  | NInstr { i = AssignAnon (lval, Lambda _); _ } -> (
+      match LV.lval_is_lambda lambdas lval with
+      | Some (_name, lambda_cfg) ->
+          let mapping = fixpoint_with_env lang in_env lambda_cfg in
+          update_svalue lambda_cfg.cfg mapping;
+          let lambda_env =
+            mapping.(lambda_cfg.cfg.exit).Dataflow_core.out_env
+          in
+          let out_env =
+            (* We look at the svalue info resulting from analyzing the lambda's
+               * body, and we kill the svalue info of any variables that may be altered
+               * by the execution of the lambda. *)
+            VarMap.merge
+              (fun _var opt_env opt_lam ->
+                match (opt_env, opt_lam) with
+                | Some sval1, Some sval2 when Eval.eq sval1 sval2 ->
+                    (* Nothing changed so we keep the same svalue info. *)
+                    opt_env
+                (* Either the svalue of 'var' changed or it was deemed non-constant
+                 * (see 'update_env_with'), once the lambda is defined we don't know
+                 * when it's going to execute, so we just kill the svalue info.
+                 * (We could do better.) *)
+                | Some _, Some _
+                | Some _, None ->
+                    None
+                (* Some new constant was found inside the lambda, but we ignore those
+                 * outside the lambda. *)
+                | None, Some _ -> None
+                (* Cannot happen, just to please the compiler. *)
+                | None, None -> None)
+              in_env lambda_env
+          in
+          out_env
+      | None -> in_env)
+  | __else__ -> in_env
+
+and fixpoint_with_env lang enter_env fun_cfg =
+  let flow = fun_cfg.cfg in
+  let mapping, timeout =
+    DataflowX.fixpoint ~timeout:Limits_semgrep.svalue_prop_FIXPOINT_TIMEOUT
+      ~eq_env:(Var_env.eq_env Eval.eq)
+      ~init:(DataflowX.new_node_array flow (Var_env.empty_inout ()))
+      ~trans:(transfer ~lang ~enter_env ~fun_cfg)
+        (* svalue is a forward analysis! *)
+      ~forward:true ~flow
+  in
+  if timeout =*= `Timeout then
+    Log.warn (fun m -> m "Fixpoint timeout while performing svalue-propagation");
+  mapping
+
 (*****************************************************************************)
 (* Entry point *)
 (*****************************************************************************)
 
-let (fixpoint : Lang.t -> IL.fdef_cfg -> mapping) =
- fun lang { fparams = _; fcfg = flow } ->
+and (fixpoint : Lang.t -> IL.fun_cfg -> mapping) =
+ fun lang fun_cfg ->
   let enter_env = VarMap.empty in
-  DataflowX.fixpoint ~timeout:Limits_semgrep.svalue_prop_FIXPOINT_TIMEOUT
-    ~eq_env:(Var_env.eq_env Eval.eq)
-    ~init:(DataflowX.new_node_array flow (Var_env.empty_inout ()))
-    ~trans:(transfer ~lang ~enter_env ~flow) (* svalue is a forward analysis! *)
-    ~forward:true ~flow
+  fixpoint_with_env lang enter_env fun_cfg
 
-let update_svalue (flow : F.cfg) mapping =
+and update_svalue (flow : F.cfg) mapping =
   flow.graph#nodes#keys
   |> List.iter (fun ni ->
          let ni_info = mapping.(ni) in
diff --git a/src/analyzing/Dataflow_svalue.mli b/src/analyzing/Dataflow_svalue.mli
index 45e8203bc4e6..385d67b1a77a 100644
--- a/src/analyzing/Dataflow_svalue.mli
+++ b/src/analyzing/Dataflow_svalue.mli
@@ -19,7 +19,7 @@ val eq : AST_generic.svalue -> AST_generic.svalue -> bool
 val union : AST_generic.svalue -> AST_generic.svalue -> AST_generic.svalue
 *)
 
-val fixpoint : Lang.t -> IL.fdef_cfg -> mapping
+val fixpoint : Lang.t -> IL.fun_cfg -> mapping
 (** Flow-sensitive constant-propagation.
  * !Note that this assumes Naming_AST.resolve has been called before!
 *)
diff --git a/src/analyzing/Dataflow_when.ml b/src/analyzing/Dataflow_when.ml
index e885354abf15..f205a4352bec 100644
--- a/src/analyzing/Dataflow_when.ml
+++ b/src/analyzing/Dataflow_when.ml
@@ -230,7 +230,10 @@ let rec annotate_facts_of_node (cfg : IL.cfg) nodei (ctx : ctx) visited =
         | TrueNode _
         | FalseNode _ ->
             process_successors ([] :: ctx)
-        (* based on the assumption in CFG_build.cfg_lambda that we don't actual traverse
+        (* TODO: Need to revisit this after a major refactoring of how we handle lambdas
+         *       in Pro PR #2477 (that PR also removed 'NLambda' nodes).
+         *
+         * based on the assumption in CFG_build.cfg_lambda that we don't actual traverse
          * into the lambda and perform inter-procedural analysis, we keep the
          * same ctx when we encounter NLambda.
          *
@@ -256,8 +259,6 @@ let rec annotate_facts_of_node (cfg : IL.cfg) nodei (ctx : ctx) visited =
          * we also cannot simply clear the ctx because if we do, we cannot
          * revert it back to the original ctx after exiting the lambda.
          *)
-        | NLambda _
-        | OtherJoin
         | NCond _
         | NInstr _
         | Enter
diff --git a/src/analyzing/Implicit_return.ml b/src/analyzing/Implicit_return.ml
index 17aa9f4a0aed..b7a504c4e890 100644
--- a/src/analyzing/Implicit_return.ml
+++ b/src/analyzing/Implicit_return.ml
@@ -44,8 +44,7 @@ let rec mark_first_instr_ancestor (cfg : IL.cfg) i =
   | Exit
   | NOther (Noop _)
   | NGoto _
-  | Join
-  | OtherJoin ->
+  | Join ->
       CFG.predecessors cfg i
       |> List.iter (fun (pred_i, _) -> mark_first_instr_ancestor cfg pred_i)
   (* Certain instruction nodes may be implicitly returned. *)
@@ -76,3 +75,23 @@ let mark_implicit_return_nodes (cfg : IL.cfg) =
    * first instruction node along each path.
    *)
   mark_first_instr_ancestor cfg cfg.exit
+
+let mark_implicit_return_fdef lang ~tok fdef =
+  let fdef_il = AST_to_IL.function_definition lang fdef in
+  let fcfg, _flambdas = CFG_build.cfg_of_stmts ~tok fdef_il.fbody in
+  (* Lambdas are separately visited by 'mark_implicit_return',
+   * see 'LambdaKind' case. *)
+  mark_implicit_return_nodes fcfg
+
+let mark_implicit_return lang ast =
+  ast
+  |> Visit_function_defs.visit (fun _ent fdef ->
+         let fkind, tok = fdef.fkind in
+         match fkind with
+         | __any__ when lang_supports_implicit_return lang ->
+             mark_implicit_return_fdef lang ~tok fdef
+         | LambdaKind ->
+             (* Lambdas expressions tend to always support implicit returns,
+              * even in languages that require explicit returns like Java. *)
+             mark_implicit_return_fdef lang ~tok fdef
+         | __else__ -> ())
diff --git a/src/analyzing/Implicit_return.mli b/src/analyzing/Implicit_return.mli
index 2a69f710db6d..4e1b4f72d42f 100644
--- a/src/analyzing/Implicit_return.mli
+++ b/src/analyzing/Implicit_return.mli
@@ -1,7 +1,8 @@
 (* True when the given language supports implicit returns. *)
 val lang_supports_implicit_return : Lang.t -> bool
 
-(* For the given CFG, mark all expression nodes that may be executed before
+(* For the given AST, mark all expression nodes that may be executed before
  * exiting a function as "returning nodes".
+ * See the 'is_implicit_return' expr field in AST_generic.
  *)
-val mark_implicit_return_nodes : IL.cfg -> unit
+val mark_implicit_return : Lang.t -> AST_generic.program -> unit
diff --git a/src/analyzing/Visit_function_defs.ml b/src/analyzing/Visit_function_defs.ml
index 6773422e43f9..9bd291d8180f 100644
--- a/src/analyzing/Visit_function_defs.ml
+++ b/src/analyzing/Visit_function_defs.ml
@@ -24,17 +24,16 @@ class ['self] visitor =
       match def_kind with
       | G.FuncDef fdef ->
           f (Some ent) fdef;
-          (* go into nested functions
-             but do NOT revisit the function definition again
-             with `kfunction_definition` below! *)
+          (* Go into nested functions
+             but do NOT revisit the function definition again! *)
           let body = H.funcbody_to_stmt fdef.G.fbody in
           self#visit_stmt f body
       | __else__ -> super#visit_definition f def
 
-    method! visit_function_definition f def =
-      f None def;
+    method! visit_function_definition f fdef =
+      f None fdef;
       (* go into nested functions *)
-      super#visit_function_definition f def
+      super#visit_function_definition f fdef
   end
 
 let visitor_instance = lazy (new visitor)
diff --git a/src/analyzing/Visit_wildcard_imports.ml b/src/analyzing/Visit_wildcard_imports.ml
index 95b291fcd848..c76a45bfe132 100644
--- a/src/analyzing/Visit_wildcard_imports.ml
+++ b/src/analyzing/Visit_wildcard_imports.ml
@@ -1,6 +1,6 @@
 (* Brandon Wu
  *
- * Copyright (C) 2023 r2c
+ * Copyright (C) 2023 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
@@ -36,11 +36,14 @@ module G = AST_generic
        !ids
      [@@profiling]
 
-   This is more intensive, and will result in us tracking all wildcard imports, even
-   those occurring not at the top level of the program.
+   This is more intensive, and will result in us tracking all wildcard imports,
+   even those occurring not at the top level of the program.
 
-   To make sure we don't affect perf, and so we don't have more false positives, let's
-   not consider those.
+   To make sure we don't affect perf, and so we don't have more false positives,
+   let's not consider those.
+
+   UPDATE: we now have LocalImportAll in AST_generic.ml and we perform
+   scoped wildcard imports in Generic_vs_generic.ml
 *)
 
 let visit_toplevel prog : G.dotted_ident list =
diff --git a/src/configuring/Limits_semgrep.ml b/src/configuring/Limits_semgrep.ml
index 8d0d9db63764..0d6b4095a078 100644
--- a/src/configuring/Limits_semgrep.ml
+++ b/src/configuring/Limits_semgrep.ml
@@ -1,3 +1,15 @@
+(*****************************************************************************)
+(* Dataflow analysis *)
+(*****************************************************************************)
+
+(* Minimum number of fixpoint iterations to run before checking for timeouts,
+ * see 'svalue_prop_FIXPOINT_TIMEOUT' and 'taint_FIXPOINT_TIMEOUT' below. It
+ * appears that when Semgrep is allocating too much memory, perhaps due to the
+ * GC having to do heavy work, we start to get fixpoint timeouts on functions
+ * that should perfectly run within the timeout. But if we just bump the timeouts
+ * then that makes perf worse and in some cases even leads to more rule timeouts. *)
+let dataflow_FIXPOINT_MIN_ITERS = 42
+
 (*****************************************************************************)
 (* Const/sym ("svalue") propagation *)
 (*****************************************************************************)
@@ -6,7 +18,7 @@
 (* Timeout in seconds.
  * So e.g. the perf of svalue-prop does not prevent rules from running on a file.
  * Note that 'Time_limit.set_timeout' cannot be nested. *)
-let svalue_prop_FIXPOINT_TIMEOUT = 0.1
+let svalue_prop_FIXPOINT_TIMEOUT = 0.15
 
 (* Bounds the number of times that we will follow an 'id_svalue' during
  * a cycle check. See 'Dataflow_svalue.no_cycles_in_svalue'. *)
@@ -33,7 +45,7 @@ let svalue_prop_MAX_VISIT_SYM_IN_CYCLE_CHECK = 1000
 (* Timeout in seconds.
  * So e.g. we limit the amount of time that Pro will spend inferring taint signatures.
  * Note that 'Time_limit.set_timeout' cannot be nested. *)
-let taint_FIXPOINT_TIMEOUT = 0.1
+let taint_FIXPOINT_TIMEOUT = 0.15
 
 (** Bounds the number of variables we can track. *)
 let taint_MAX_TAINTED_VARS = 50
diff --git a/src/core/Core_error.ml b/src/core/Core_error.ml
index 08399358e24b..256976daf51f 100644
--- a/src/core/Core_error.ml
+++ b/src/core/Core_error.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2021-2023 Semgrep Inc.
+ * Copyright (C) 2021-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
@@ -35,27 +35,25 @@ module Log = Log_semgrep.Log
 (* Types and globals *)
 (****************************************************************************)
 
-(* See also try_with_exn_to_errors(), try_with_error_loc_and_reraise(), and
- * filter_maybe_parse_and_fatal_errors.
- * less: we should define everything in semgrep_output_v1.atd, not just typ:
+(* less: we should define everything in semgrep_output_v1.atd, not just typ:
+ * coupling: almost identical to semgrep_output_v1.core_error
  *)
 type t = {
   typ : Out.error_type;
-  (* TODO: get rid of option.
-   * Is None when we call error_of_rule_error() or exn_to_error() with an
-   * Fpath_.fake_file
-   * TODO: get rid of Fpath_.fake_file call sites
+  msg : string;
+  (* This is None when we captured a fatal error that can't be
+   * attached to a file. See exn_to_error() for example.
    *)
   loc : Tok.location option;
-  msg : string;
-  details : string option;
   rule_id : Rule_ID.t option;
+  (* TODO? diff with msg? *)
+  details : string option;
 }
 [@@deriving show]
 
-(* ugly alias because 'type t = t' is not allowed *)
-type core_error = t
-
+(* Used only in pro in Deep_scan_phases.ml
+ * TODO? we should probably get rid of it
+ *)
 exception Unhandled_core_error of t
 
 let () =
@@ -66,6 +64,9 @@ let () =
              (show core_error))
     | _ -> None)
 
+(* ugly alias because 'type t = t' is not allowed in ErrorSet below *)
+type core_error = t
+
 (* TODO: use Set_.t instead *)
 module ErrorSet = Set.Make (struct
   type t = core_error
@@ -74,15 +75,15 @@ module ErrorSet = Set.Make (struct
 end)
 
 (****************************************************************************)
-(* Convertor functions *)
+(* Error builder *)
 (****************************************************************************)
 
 let please_file_issue_text =
   "An error occurred while invoking the Semgrep engine. Please help us fix \
    this by creating an issue at https://github.com/returntocorp/semgrep"
 
-let mk_error ?rule_id ?(msg = "") (loc : Tok.location) (err : Out.error_type) :
-    t =
+let mk_error ?rule_id ?(msg = "") ?(loc : Tok.location option)
+    (err : Out.error_type) : t =
   let msg =
     match err with
     | MatchingError
@@ -113,16 +114,29 @@ let mk_error ?rule_id ?(msg = "") (loc : Tok.location) (err : Out.error_type) :
     | MissingPlugin ->
         msg
   in
-  { loc = Some loc; typ = err; msg; details = None; rule_id }
+  { loc; typ = err; msg; details = None; rule_id }
 
-let mk_error_tok opt_rule_id (file : Fpath.t) (tok : Tok.t) (msg : string)
+(* Why a file in addition to tok? Can't we just use Tok.file_of_tok on it?
+ * Because in some cases this may be a fake tok with a wrong file,
+ * and in some situation the caller might know the file we are currently
+ * processing hence the extra file parameter.
+ * TODO: this is still complicated, we should get rid of file and
+ * enforce that every tok has a correct file pos.
+ *)
+let mk_error_tok ?rule_id ?(file : Fpath.t option) (tok : Tok.t) (msg : string)
     (err : Out.error_type) : t =
   let loc =
     match Tok.loc_of_tok tok with
-    | Ok loc -> loc
-    | Error _ -> Tok.first_loc_of_file !!file
+    | Ok loc -> Some loc
+    | Error _ ->
+        let* file = file in
+        Some (Tok.first_loc_of_file file)
   in
-  mk_error ?rule_id:opt_rule_id ~msg loc err
+  mk_error ?rule_id ~msg ?loc err
+
+(****************************************************************************)
+(* Error of xxx *)
+(****************************************************************************)
 
 let error_of_invalid_rule ((kind, rule_id, pos) : Rule_error.invalid_rule) : t =
   let msg = Rule_error.string_of_invalid_rule_kind kind in
@@ -139,11 +153,11 @@ let error_of_invalid_rule ((kind, rule_id, pos) : Rule_error.invalid_rule) : t =
     | MissingPlugin _msg -> Out.MissingPlugin
     | _ -> Out.RuleParseError
   in
-  (* TODO: bad use of fake_file, use pos? *)
-  mk_error_tok (Some rule_id) Fpath_.fake_file pos msg err
+  mk_error_tok ~rule_id pos msg err
 
-let error_of_rule_error (file : Fpath.t) (err : Rule_error.t) : t =
+let error_of_rule_error (err : Rule_error.t) : t =
   let rule_id = err.rule_id in
+  let file = err.file in
   match err.kind with
   | InvalidRule
       (InvalidPattern (pattern, xlang, message, yaml_path), rule_id, pos) ->
@@ -163,18 +177,14 @@ let error_of_rule_error (file : Fpath.t) (err : Rule_error.t) : t =
         details = None;
       }
   | InvalidRule err -> error_of_invalid_rule err
-  | InvalidYaml (msg, pos) -> mk_error_tok rule_id file pos msg Out.InvalidYaml
-  | DuplicateYamlKey (s, pos) -> mk_error_tok rule_id file pos s Out.InvalidYaml
-  (* TODO?? *)
+  | InvalidYaml (msg, pos) ->
+      mk_error_tok ?rule_id ~file pos msg Out.InvalidYaml
+  | DuplicateYamlKey (s, pos) ->
+      mk_error_tok ?rule_id ~file pos s Out.InvalidYaml
   | UnparsableYamlException msg ->
-      (* Based on what previously happened based on exn_to_error logic before
-         converting Rule parsing errors to not be exceptions. *)
-      if not (Fpath_.is_fake_file file) then
-        mk_error ?rule_id ~msg
-          (Tok.first_loc_of_file !!file)
-          Out.OtherParseError
-      else
-        { loc = None; typ = Out.OtherParseError; msg; details = None; rule_id }
+      mk_error ?rule_id ~msg
+        ~loc:(Tok.first_loc_of_file file)
+        Out.OtherParseError
 
 (*
    This function converts known exceptions to Semgrep errors.
@@ -188,14 +198,13 @@ let error_of_rule_error (file : Fpath.t) (err : Rule_error.t) : t =
    reporting.
    - TODO: naming exns?
 *)
-let known_exn_to_error (rule_id : Rule_ID.t option) (file : Fpath.t)
-    (e : Exception.t) : t option =
+let known_exn_to_error ?(file : Fpath.t option) (e : Exception.t) : t option =
   match Exception.get_exn e with
   (* TODO: Move the cases handling Parsing_error.XXX to the Parsing_error
      module so that we can use it for the exception printers that are
      registered there. *)
   | Parsing_error.Lexical_error (s, tok) ->
-      Some (mk_error_tok rule_id file tok s Out.LexicalError)
+      Some (mk_error_tok ?file tok s Out.LexicalError)
   | Parsing_error.Syntax_error tok ->
       let msg =
         match tok with
@@ -208,86 +217,76 @@ let known_exn_to_error (rule_id : Rule_ID.t option) (file : Fpath.t)
         | Tok.OriginTok { str; _ } -> spf "`%s` was unexpected" str
         | __else__ -> "unknown reason"
       in
-      Some (mk_error_tok rule_id file tok msg Out.ParseError)
+      Some (mk_error_tok ?file tok msg Out.ParseError)
   | Parsing_error.Other_error (s, tok) ->
-      Some (mk_error_tok rule_id file tok s Out.OtherParseError)
+      Some (mk_error_tok ?file tok s Out.OtherParseError)
   | AST_generic.Error (s, tok) ->
-      Some (mk_error_tok rule_id file tok s Out.AstBuilderError)
+      Some (mk_error_tok ?file tok s Out.AstBuilderError)
   | Time_limit.Timeout timeout_info ->
       let s = Printexc.get_backtrace () in
       Log.warn (fun m -> m "WEIRD Timeout converted to exn, backtrace = %s" s);
       (* This exception should always be reraised. *)
-      let loc = Tok.first_loc_of_file !!file in
+      let loc =
+        let* file = file in
+        Some (Tok.first_loc_of_file file)
+      in
       let msg = Time_limit.string_of_timeout_info timeout_info in
-      Some (mk_error ?rule_id ~msg loc Out.Timeout)
+      Some (mk_error ~msg ?loc Out.Timeout)
   | Memory_limit.ExceededMemoryLimit msg ->
-      let loc = Tok.first_loc_of_file !!file in
-      Some (mk_error ?rule_id ~msg loc Out.OutOfMemory)
+      let loc =
+        let* file = file in
+        Some (Tok.first_loc_of_file file)
+      in
+      Some (mk_error ~msg ?loc Out.OutOfMemory)
   | Out_of_memory ->
-      let loc = Tok.first_loc_of_file !!file in
-      Some (mk_error ?rule_id ~msg:"Heap space exceeded" loc Out.OutOfMemory)
+      let loc =
+        let* file = file in
+        Some (Tok.first_loc_of_file file)
+      in
+      Some (mk_error ~msg:"Heap space exceeded" ?loc Out.OutOfMemory)
   (* general case, can't extract line information from it, default to line 1 *)
   | _exn -> None
 
-let exn_to_error (rule_id : Rule_ID.t option) (file : Fpath.t) (e : Exception.t)
-    : t =
-  match known_exn_to_error rule_id file e with
+let exn_to_error ?(file : Fpath.t option) (e : Exception.t) : t =
+  match known_exn_to_error ?file e with
   | Some err -> err
-  | None -> (
-      match Exception.get_exn e with
-      | UnixExit _ ->
-          (* TODO: remove this.
-             This exception shouldn't be passed to this function
-             in the first place. *)
-          Exception.reraise e
-      | exn ->
-          let trace = Exception.to_string e in
-          let loc =
-            (* TODO: we shouldn't build Tok.t w/out a filename, but
-               lets do it here so we don't crash until we do *)
-            if not (Fpath_.is_fake_file file) then
-              Some (Tok.first_loc_of_file !!file)
-            else None
-          in
-          {
-            rule_id;
-            (* bugfix: we used to return [Out.FatalError] here, but pysemgrep
-             * has some special handling for such error and aborts
-             * aggressively the scan and display a scary stack trace.
-             * We are probably here because of an unhandled exn
-             * in one of the parser (e.g., Failure "not a program") but
-             * we can recover from it, so let's generate a OtherParseError
-             * instead.
-             *)
-            typ = Out.OtherParseError;
-            loc;
-            msg = Printexc.to_string exn;
-            details = Some trace;
-          })
+  | None ->
+      let exn = Exception.get_exn e in
+      let trace = Exception.to_string e in
+      let loc =
+        let* file = file in
+        Some (Tok.first_loc_of_file file)
+      in
+      {
+        rule_id = None;
+        typ = Out.FatalError;
+        loc;
+        msg = Printexc.to_string exn;
+        details = Some trace;
+      }
 
 (*****************************************************************************)
 (* Pretty printers *)
 (*****************************************************************************)
 
-let source_of_string = function
-  | "" -> "<input>"
-  | path -> path
-
-let string_of_error err =
-  let pos = err.loc in
+let string_of_error (err : t) : string =
   let details =
     match err.details with
     | None -> ""
     | Some s -> spf "\n%s" s
   in
   let loc =
-    match pos with
+    match err.loc with
     | None -> "<unknown location>"
     | Some { pos = { file; line; column; _ }; _ } ->
-        spf "%s:%d:%d" (source_of_string !!file) line column
+        spf "%s:%d:%d" !!file line column
   in
   spf "%s: %s: %s%s" loc (Out.string_of_error_type err.typ) err.msg details
 
+(****************************************************************************)
+(* Misc *)
+(****************************************************************************)
+
 let severity_of_error (typ : Out.error_type) : Out.error_severity =
   match typ with
   | SemgrepMatchFound -> `Error
@@ -318,24 +317,3 @@ let severity_of_error (typ : Out.error_type) : Out.error_severity =
   | IncompatibleRule0 ->
       `Info
   | MissingPlugin -> `Info
-
-(*****************************************************************************)
-(* Try with error, mostly used in testing code *)
-(*****************************************************************************)
-
-let try_with_result_to_error (file : Fpath.t) f =
-  try f () with
-  | Time_limit.Timeout _ as exn -> Exception.catch_and_reraise exn
-  | exn ->
-      let e = Exception.catch exn in
-      Error (exn_to_error None file e)
-
-let try_with_log_exn_and_reraise (file : Fpath.t) f =
-  try f () with
-  | Time_limit.Timeout _ as exn -> Exception.catch_and_reraise exn
-  | exn ->
-      let e = Exception.catch exn in
-      let err = exn_to_error None file e in
-      (* nosemgrep: no-logs-in-library *)
-      Logs.err (fun m -> m "%s" (string_of_error err));
-      Exception.reraise e
diff --git a/src/core/Core_error.mli b/src/core/Core_error.mli
index e2da606d1fe4..5e1af4e72d09 100644
--- a/src/core/Core_error.mli
+++ b/src/core/Core_error.mli
@@ -8,11 +8,10 @@
 
 type t = {
   typ : Semgrep_output_v1_t.error_type;
-  loc : Tok.location option;
   msg : string;
-  (* ?? diff with msg? *)
-  details : string option;
+  loc : Tok.location option;
   rule_id : Rule_ID.t option;
+  details : string option;
 }
 [@@deriving show]
 
@@ -28,7 +27,7 @@ module ErrorSet : Set.S with type elt = t
 val mk_error :
   ?rule_id:Rule_ID.t ->
   ?msg:string ->
-  Tok.location ->
+  ?loc:Tok.location ->
   Semgrep_output_v1_t.error_type ->
   t
 
@@ -37,21 +36,12 @@ val mk_error :
    mismatches.
 *)
 val error_of_invalid_rule : Rule_error.invalid_rule -> t
-val error_of_rule_error : Fpath.t -> Rule_error.t -> t
+val error_of_rule_error : Rule_error.t -> t
 
 (* Convert a caught exception and its stack trace to a Semgrep error.
  * See also JSON_report.json_of_exn for non-target related exn handling.
  *)
-val exn_to_error : Rule_ID.t option -> Fpath.t -> Exception.t -> t
-
-(*****************************************************************************)
-(* Try with error *)
-(*****************************************************************************)
-
-val try_with_result_to_error :
-  Fpath.t -> (unit -> ('a, t) Result.t) -> ('a, t) Result.t
-
-val try_with_log_exn_and_reraise : Fpath.t -> (unit -> 'a) -> 'a
+val exn_to_error : ?file:Fpath.t -> Exception.t -> t
 
 (*****************************************************************************)
 (* Pretty printers *)
diff --git a/src/core/Core_result.ml b/src/core/Core_result.ml
index ecf8efd6a476..b19ee867bfd5 100644
--- a/src/core/Core_result.ml
+++ b/src/core/Core_result.ml
@@ -109,6 +109,7 @@ type t = {
   scanned : Target.t list;
   skipped_targets : Semgrep_output_v1_t.skipped_target list;
   skipped_rules : Rule_error.invalid_rule list;
+  valid_rules : Rule.rule list;
   rules_with_targets : Rule.rule list;
   profiling : Core_profiling.t option;
   explanations : Matching_explanation.t list option;
@@ -142,6 +143,7 @@ let mk_result_with_just_errors (errors : Core_error.t list) : t =
     errors;
     (* default values *)
     processed_matches = [];
+    valid_rules = [];
     rules_with_targets = [];
     skipped_targets = [];
     scanned = [];
@@ -316,6 +318,7 @@ let mk_result (results : Core_profiling.file_profiling match_result list)
     scanned;
     skipped_targets = [];
     skipped_rules;
+    valid_rules = [];
     rules_with_targets = [];
     explanations = (if explanations =*= [] then None else Some explanations);
     rules_by_engine =
diff --git a/src/core/Core_result.mli b/src/core/Core_result.mli
index 034b07d1bd03..f49708ec38b6 100644
--- a/src/core/Core_result.mli
+++ b/src/core/Core_result.mli
@@ -24,6 +24,11 @@ type t = {
    *)
   skipped_targets : Semgrep_output_v1_t.skipped_target list;
   skipped_rules : Rule_error.invalid_rule list;
+  (*
+     valid_rules: deduplicated, valid rules
+     rules_with_targets: subset of valid_rules applicable to one or more target
+  *)
+  valid_rules : Rule.rule list;
   rules_with_targets : Rule.rule list;
   profiling : Core_profiling.t option;
   explanations : Matching_explanation.t list option;
diff --git a/src/core_cli/Core_CLI.ml b/src/core_cli/Core_CLI.ml
index 1480c55281ae..f69aebf6a5ab 100644
--- a/src/core_cli/Core_CLI.ml
+++ b/src/core_cli/Core_CLI.ml
@@ -167,7 +167,7 @@ let dump_pattern (file : Fpath.t) =
   let s = UFile.read_file file in
   (* mostly copy-paste of parse_pattern in runner, but with better error report *)
   let lang = Xlang.lang_of_opt_xlang_exn !lang in
-  E.try_with_log_exn_and_reraise file (fun () ->
+  Core_actions.try_with_log_exn_and_reraise file (fun () ->
       (* TODO? enable "semgrep.parsing" log level *)
       match Parse_pattern.parse_pattern lang s with
       | Ok any ->
@@ -198,7 +198,7 @@ let dump_patterns_of_rule (file : Fpath.t) =
 (* TODO: remove, deprecated by osemgrep show dump-ast *)
 let dump_ast ?(naming = false) (caps : < Cap.stdout ; Cap.exit >)
     (lang : Language.t) (file : Fpath.t) =
-  E.try_with_log_exn_and_reraise file (fun () ->
+  Core_actions.try_with_log_exn_and_reraise file (fun () ->
       let res =
         if naming then Parse_target.parse_and_resolve_name lang file
         else Parse_target.just_parse_with_lang lang file
@@ -240,7 +240,7 @@ let output_core_results (caps : < Cap.stdout ; Cap.stderr ; Cap.exit >)
         match result_or_exn with
         | Ok r -> r
         | Error exn ->
-            let err = E.exn_to_error None Fpath_.fake_file exn in
+            let err = E.exn_to_error exn in
             Core_result.mk_result_with_just_errors [ err ]
       in
       let res =
@@ -331,16 +331,22 @@ let mk_config () : Core_scan_config.t =
     tracing =
       (match (!trace, !trace_endpoint) with
       | true, Some url ->
-          let endpoint =
+          let endpoint, env =
             match url with
-            | "semgrep-prod" -> default_trace_endpoint
-            | "semgrep-dev" -> default_dev_endpoint
-            | "semgrep-local" -> default_local_endpoint
-            | _ -> Uri.of_string url
+            (* coupling: cli/src/semgrep/tracing.py _ENV_ALIASES *)
+            | "semgrep-prod" -> (default_trace_endpoint, Some "prod")
+            | "semgrep-dev" -> (default_dev_endpoint, Some "dev2")
+            | "semgrep-local" -> (default_local_endpoint, Some "local")
+            | _ -> (Uri.of_string url, None)
           in
-          Some { endpoint; top_level_span = None }
+          Some { endpoint; top_level_span = None; env }
       | true, None ->
-          Some { endpoint = default_trace_endpoint; top_level_span = None }
+          Some
+            {
+              endpoint = default_trace_endpoint;
+              top_level_span = None;
+              env = None;
+            }
       | false, Some _ ->
           Logs.warn (fun m ->
               m
@@ -715,6 +721,22 @@ let register_exception_printers () =
   Pcre_.register_exception_printer ()
 [@@alert "-deprecated"]
 
+(*****************************************************************************)
+(* Run a scan *)
+(*****************************************************************************)
+(* TODO: We used to tune the garbage collector but from profiling
+   we found that the effect was small. Meanwhile, the memory
+   consumption causes some machines to freeze. We may want to
+   tune these parameters in the future/do more testing, but
+   for now just turn it off *)
+(* if !Flag.gc_tuning && config.max_memory_mb = 0 then set_gc (); *)
+
+let run caps (config : Core_scan_config.t) : unit =
+  let res = Core_scan.scan (caps :> Core_scan.caps) config in
+  output_core_results
+    (caps :> < Cap.stdout ; Cap.stderr ; Cap.exit >)
+    res config
+
 (*****************************************************************************)
 (* Main entry point *)
 (*****************************************************************************)
@@ -821,18 +843,6 @@ let main_exn (caps : Cap.all_caps) (argv : string array) : unit =
           in
           let config = { config with target_source; ncores } in
 
-          (* TODO: We used to tune the garbage collector but from profiling
-             we found that the effect was small. Meanwhile, the memory
-             consumption causes some machines to freeze. We may want to
-             tune these parameters in the future/do more testing, but
-             for now just turn it off *)
-          (* if !Flag.gc_tuning && config.max_memory_mb = 0 then set_gc (); *)
-          let run config =
-            let res = Core_scan.scan (caps :> Core_scan.caps) config in
-            output_core_results
-              (caps :> < Cap.stdout ; Cap.stderr ; Cap.exit >)
-              res config
-          in
           (* Set up tracing and run it for the duration of scanning. Note that
              this will only trace `Core_command.run_conf` and the functions it
              calls.
@@ -840,19 +850,23 @@ let main_exn (caps : Cap.all_caps) (argv : string array) : unit =
              able to instrument the pre- and post-scan code in the same way.
           *)
           match config.tracing with
-          | None -> run config
+          | None -> run caps config
           | Some tracing ->
               let trace_data =
                 Trace_data.get_top_level_data config.ncores Version.version
                   (Trace_data.no_analysis_features ())
               in
-              Tracing.configure_tracing "semgrep-oss" tracing.endpoint;
+              Tracing.configure_tracing
+              (* Let's make sure all traces/logs/metrics etc. are tagged as coming from the pro invocation *)
+                ~attrs:[ ("engine", `String "oss") ]
+                ?env:tracing.env ~version:Version.version "semgrep-core"
+                tracing.endpoint;
               Tracing.with_tracing "Core_command.semgrep_core_dispatch"
                 trace_data (fun span_id ->
                   let tracing =
                     { tracing with top_level_span = Some span_id }
                   in
-                  run { config with tracing = Some tracing })))
+                  run caps { config with tracing = Some tracing })))
 
 let with_exception_trace f =
   Printexc.record_backtrace true;
diff --git a/src/core_cli/Core_actions.ml b/src/core_cli/Core_actions.ml
index ea36c34407fd..93611ef2324e 100644
--- a/src/core_cli/Core_actions.ml
+++ b/src/core_cli/Core_actions.ml
@@ -41,6 +41,19 @@ let json_of_v (v : OCaml.v) =
   in
   aux v
 
+(* used to be in Core_error.mli but better here as should be used
+ * only in test code.
+ * val try_with_log_exn_and_reraise : Fpath.t -> (unit -> 'a) -> 'a
+ *)
+let try_with_log_exn_and_reraise (file : Fpath.t) f =
+  try f () with
+  | Time_limit.Timeout _ as exn -> Exception.catch_and_reraise exn
+  | exn ->
+      let e = Exception.catch exn in
+      let err = E.exn_to_error ~file e in
+      Logs.err (fun m -> m "%s" (E.string_of_error err));
+      Exception.reraise e
+
 (*****************************************************************************)
 (* Dumpers *)
 (*****************************************************************************)
@@ -101,7 +114,7 @@ let dump_v1_json ~(get_lang : unit -> Language.t option) file =
         | x :: _ -> x
         | [] -> failwith (spf "unsupported language for %s" !!file))
   in
-  E.try_with_log_exn_and_reraise file (fun () ->
+  try_with_log_exn_and_reraise file (fun () ->
       let { Parsing_result2.ast; skipped_tokens; _ } =
         Parse_target.parse_and_resolve_name lang file
       in
diff --git a/src/core_scan/Core_scan.ml b/src/core_scan/Core_scan.ml
index f68728785f4c..e2690b19b486 100644
--- a/src/core_scan/Core_scan.ml
+++ b/src/core_scan/Core_scan.ml
@@ -188,7 +188,7 @@ let filter_files_with_too_many_matches_and_transform_as_timeout
   let per_files =
     matches
     |> List_.map (fun ({ pm; _ } : Core_result.processed_match) ->
-           (!!(pm.path.internal_path_to_content), pm))
+           (pm.path.internal_path_to_content, pm))
     |> Assoc.group_assoc_bykey_eff
   in
 
@@ -201,14 +201,14 @@ let filter_files_with_too_many_matches_and_transform_as_timeout
   let new_matches =
     matches
     |> List_.exclude (fun ({ pm; _ } : Core_result.processed_match) ->
-           Hashtbl.mem offending_files !!(pm.path.internal_path_to_content))
+           Hashtbl.mem offending_files pm.path.internal_path_to_content)
   in
   let new_errors, new_skipped =
     offending_file_list
-    |> List_.map (fun file ->
+    |> List_.map (fun (file : Fpath.t) ->
            (* logging useful info for rule writers *)
            Logs.warn (fun m ->
-               m "too many matches on %s, generating exn for it" file);
+               m "too many matches on %s, generating exn for it" !!file);
            let sorted_offending_rules =
              let matches = List.assoc file per_files in
              matches
@@ -242,7 +242,7 @@ let filter_files_with_too_many_matches_and_transform_as_timeout
                     "%d rules result in too many matches, most offending rule \
                      has %d: %s"
                     offending_rules cnt pat)
-               loc Out.TooManyMatches
+               ~loc Out.TooManyMatches
            in
            let skipped =
              sorted_offending_rules
@@ -257,7 +257,7 @@ let filter_files_with_too_many_matches_and_transform_as_timeout
                            max_match_per_file)
                     in
                     {
-                      Semgrep_output_v1_t.path = Fpath.v file;
+                      Semgrep_output_v1_t.path = file;
                       reason = Too_many_matches;
                       details;
                       rule_id = Some rule_id;
@@ -491,7 +491,7 @@ let log_critical_exn_and_last_rule () =
 let errors_of_timeout_or_memory_exn (exn : exn) (target : Target.t) : ESet.t =
   let internal_path = Target.internal_path target in
   let origin = Target.origin target in
-  let loc = Tok.first_loc_of_file !!internal_path in
+  let loc = Tok.first_loc_of_file internal_path in
   match exn with
   | Match_rules.File_timeout rule_ids ->
       Logs.warn (fun m -> m "Timeout on %s" (Origin.to_string origin));
@@ -507,16 +507,16 @@ let errors_of_timeout_or_memory_exn (exn : exn) (target : Target.t) : ESet.t =
       *)
       rule_ids
       |> List_.map (fun error_rule_id ->
-             E.mk_error ~rule_id:error_rule_id loc Out.Timeout)
+             E.mk_error ~rule_id:error_rule_id ~loc Out.Timeout)
       |> ESet.of_list
   | Out_of_memory ->
       Logs.warn (fun m -> m "OutOfMemory on %s" (Origin.to_string origin));
       ESet.singleton
-        (E.mk_error ?rule_id:!Rule.last_matched_rule loc Out.OutOfMemory)
+        (E.mk_error ?rule_id:!Rule.last_matched_rule ~loc Out.OutOfMemory)
   | Stack_overflow ->
       Logs.warn (fun m -> m "StackOverflow on %s" (Origin.to_string origin));
       ESet.singleton
-        (E.mk_error ?rule_id:!Rule.last_matched_rule loc Out.StackOverflow)
+        (E.mk_error ?rule_id:!Rule.last_matched_rule ~loc Out.StackOverflow)
   | _ -> raise Impossible
 
 (*****************************************************************************)
@@ -609,12 +609,13 @@ let iter_targets_and_get_matches_and_exn_to_errors (caps : < Cap.fork >)
                   * semgrep-core program.
                   *)
                  | exn when not !Flag_semgrep.fail_fast ->
+                     (* TODO? repeat Parmap_targets.core_error_of_path_exc() *)
                      Logs.err (fun m ->
                          m "exception on %s (%s)" !!internal_path
                            (Printexc.to_string exn));
                      let e = Exception.catch exn in
                      let errors =
-                       ESet.singleton (E.exn_to_error None internal_path e)
+                       ESet.singleton (E.exn_to_error ~file:internal_path e)
                      in
                      (Core_result.mk_match_result [] errors noprof, true))
            in
@@ -857,6 +858,10 @@ let scan_exn (caps : caps) (config : Core_scan_config.t)
   Logs.debug (fun m -> m "Core_scan.scan_exn %s" (Core_scan_config.show config));
   (* the rules *)
   let (valid_rules, invalid_rules), rules_parse_time = rules in
+  Logs.debug (fun m ->
+      m "core scan: %i valid rules, %i invalid rules" (List.length valid_rules)
+        (List.length invalid_rules));
+
   let (rule_errors : E.t list) =
     invalid_rules |> List_.map E.error_of_invalid_rule
   in
diff --git a/src/core_scan/Core_scan_config.ml b/src/core_scan/Core_scan_config.ml
index abd0a05ef7b3..77903f1973b4 100644
--- a/src/core_scan/Core_scan_config.ml
+++ b/src/core_scan/Core_scan_config.ml
@@ -16,7 +16,20 @@ type output_format =
     preparsed.
 *)
 type rule_source = Rule_file of Fpath.t | Rules of Rule.t list
-[@@deriving show]
+
+(* old: was [@@deriving show] but when using --config p/default
+ * the logs were getting too big
+ *)
+let pp_rule_source (fmt : Format.formatter) (x : rule_source) : unit =
+  match x with
+  | Rule_file x -> Format.fprintf fmt "Rule_file (%a)" Fpath.pp x
+  | Rules xs ->
+      (* TODO: we should use Scan_CLI max_log_list_entries
+       * and Output.too_much_data, but hard to pass that in
+       *)
+      if List.length xs > 100 then
+        Format.fprintf fmt "<TOO MANY RULES TO DISPLAY (%d)>" (List.length xs)
+      else Format.fprintf fmt "Rules (%a)" Rule.pp_rules xs
 
 (*
    'Target_file' is for the semgrep-core CLI which gets a list of
diff --git a/src/core_scan/Parmap_targets.ml b/src/core_scan/Parmap_targets.ml
index 303d4aa3a356..68831386266d 100644
--- a/src/core_scan/Parmap_targets.ml
+++ b/src/core_scan/Parmap_targets.ml
@@ -12,6 +12,7 @@
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
  * LICENSE for more details.
  *)
+open Fpath_.Operators
 module In = Input_to_core_t
 
 (*****************************************************************************)
@@ -47,17 +48,12 @@ let sort_code_targets_by_decreasing_size (targets : Target.regular list) :
         * instead of ascending, order *)
        (Fun.flip Int.compare)
 
-(* Helper to make all the results from a simple n=1 job similar to parmap's
-   result *)
-let wrap_with_ok (f : 'b -> 'a) (x : 'b) : ('a, 'c) result = Ok (f x)
-
-let core_error_of_path_exc internal_path e =
+let core_error_of_path_exc (internal_path : Fpath.t) (e : Exception.t) :
+    Core_error.t =
   let exn = Exception.get_exn e in
   Logs.err (fun m ->
-      m "exception on %s (%s)"
-        (Fpath.to_string internal_path)
-        (Printexc.to_string exn));
-  Core_error.exn_to_error None internal_path e
+      m "exception on %s (%s)" !!internal_path (Printexc.to_string exn));
+  Core_error.exn_to_error ~file:internal_path e
 
 (*****************************************************************************)
 (* Entry point *)
@@ -123,7 +119,23 @@ let map_targets__run_in_forked_process_do_not_modify_globals caps (ncores : int)
      the two modes, we always sort the target queue in the same way.
   *)
   let targets = sort_targets_by_decreasing_size targets in
-  if ncores <= 1 then List_.map (wrap_with_ok f) targets
+
+  (* Default to core_error and the target here since that's what's most
+     usefule in Core_scan. Maybe we should instead pass this as a parameter? *)
+  let exception_handler (x : Target.t) (e : Exception.t) :
+      Target.t * Core_error.t =
+    let internal_path = Target.internal_path x in
+    (x, core_error_of_path_exc internal_path e)
+  in
+
+  (* old:
+   *    if ncores <= 1 then List_.map (fun x -> Ok (f x)) targets else ( ... )
+   * But this was wrong because 'f' can throw exns and so we would
+   * get a different semantic when ncores > 1 where we capture exns hence
+   * the use of wrap_result below.
+   *)
+  if ncores <= 1 then
+    targets |> List_.map (fun x -> Parmap_.wrap_result f ~exception_handler x)
   else (
     (*
        Parmap creates ncores children processes which listen for
@@ -147,21 +159,12 @@ let map_targets__run_in_forked_process_do_not_modify_globals caps (ncores : int)
      * this issue until this is fixed in a future version of Parmap.
      *)
     Parmap_.disable_core_pinning ();
-    assert (ncores > 0);
     (* TODO: port this functionality to Logs:
        let init _ = Logging.add_PID_tag () in
     *)
     Logs.debug (fun m ->
         m "running parmap with %d cores on %d targets" ncores
           (List.length targets));
-    (* Default to core_error and the target here since that's what's most
-       usefule in Core_scan. Maybe we should instead pass this as a
-       parameter? *)
-    let exception_handler (x : Target.t) (e : Exception.t) :
-        Target.t * Core_error.t =
-      let internal_path = Target.internal_path x in
-      (x, core_error_of_path_exc internal_path e)
-    in
     (* We must pause tracing here as forking with tracing on causes segfaults.
        See comments on this function in Tracing.ml *)
     Tracing.with_tracing_paused (fun () ->
@@ -176,22 +179,21 @@ let map_regular_targets__run_in_forked_process_do_not_modify_globals caps
     (ncores : int) (f : Target.regular -> 'a) (targets : Target.regular list) :
     ('a, Core_error.t) result list =
   let targets = sort_code_targets_by_decreasing_size targets in
-  if ncores <= 1 then List_.map (wrap_with_ok f) targets
+  (* Default to core_error here. Maybe we should instead pass this as a param? *)
+  let exception_handler
+      ({ path = { internal_path_to_content; _ }; _ } : Target.regular)
+      (e : Exception.t) : Core_error.t =
+    core_error_of_path_exc internal_path_to_content e
+  in
+  if ncores <= 1 then
+    targets |> List_.map (fun x -> Parmap_.wrap_result f ~exception_handler x)
   else (
     Parmap_.disable_core_pinning ();
-    assert (ncores > 0);
     Logs.debug (fun m ->
         m "running parmap with %d cores on %d targets" ncores
           (List.length targets));
-    (* Default to core_error here. Maybe we should instead pass this as a
-       parameter? *)
-    let exception_handler
-        ({ path = { internal_path_to_content; _ }; _ } : Target.regular)
-        (e : Exception.t) : Core_error.t =
-      core_error_of_path_exc internal_path_to_content e
-    in
     (* We must pause tracing here as forking with tracing on causes segfaults.
        See comments on this function in Tracing.ml *)
-    Tracing.with_tracing_paused @@ fun () ->
-    Parmap_.parmap caps ~init ~finalize ~ncores ~chunksize:1 ~exception_handler
-      f targets)
+    Tracing.with_tracing_paused (fun () ->
+        Parmap_.parmap caps ~init ~finalize ~ncores ~chunksize:1
+          ~exception_handler f targets))
diff --git a/src/engine/Match_env.ml b/src/engine/Match_env.ml
index 3fceb1590534..609d6b031065 100644
--- a/src/engine/Match_env.ml
+++ b/src/engine/Match_env.ml
@@ -12,7 +12,6 @@
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
  * LICENSE for more details.
  *)
-open Fpath_.Operators
 module E = Core_error
 module OutJ = Semgrep_output_v1_t
 module PM = Pattern_match
@@ -82,11 +81,11 @@ let error (env : env) msg =
   (* We are not supposed to report errors in the config file for several reasons
    * (one being that it's often a temporary file anyways), so we report them on
    * the target file. *)
-  let loc =
-    Tok.first_loc_of_file !!(env.xtarget.path.internal_path_to_content)
-  in
+  let loc = Tok.first_loc_of_file env.xtarget.path.internal_path_to_content in
   (* TODO: warning or error? MatchingError or ... ? *)
-  let err = E.mk_error ~rule_id:(fst env.rule.id) ~msg loc OutJ.MatchingError in
+  let err =
+    E.mk_error ~rule_id:(fst env.rule.id) ~msg ~loc OutJ.MatchingError
+  in
   env.errors := Core_error.ErrorSet.add err !(env.errors)
 
 (* this will be adjusted later in range_to_pattern_match_adjusted *)
diff --git a/src/engine/Match_rules.ml b/src/engine/Match_rules.ml
index 682f3b9c56e2..0c5a95a4490c 100644
--- a/src/engine/Match_rules.ml
+++ b/src/engine/Match_rules.ml
@@ -41,7 +41,7 @@ type timeout_config = { timeout : float; threshold : int; caps : < Cap.alarm > }
 (* Helpers *)
 (*****************************************************************************)
 
-let timeout_function (rule : Rule.t) (file : string)
+let timeout_function (rule : Rule.t) (file : Fpath.t)
     (timeout : timeout_config option) f =
   let timeout =
     match timeout with
@@ -59,7 +59,7 @@ let timeout_function (rule : Rule.t) (file : string)
       Log.err (fun m ->
           m "timeout for rule %s on file %s"
             (Rule_ID.to_string (fst rule.id))
-            file);
+            !!file);
       None
 
 let is_relevant_rule_for_xtarget r xconf xtarget =
@@ -130,7 +130,7 @@ let group_rules xconf rules xtarget =
 let per_rule_boilerplate_fn (timeout : timeout_config option) =
   let cnt_timeout = ref 0 in
   let rule_timeouts = ref [] in
-  fun (file : string) (rule : Rule.t) f ->
+  fun (file : Fpath.t) (rule : Rule.t) f ->
     let rule_id = fst rule.R.id in
     Rule.last_matched_rule := Some rule_id;
     let res_opt =
@@ -151,7 +151,7 @@ let per_rule_boilerplate_fn (timeout : timeout_config option) =
             raise (File_timeout !rule_timeouts)
         | _else_ -> ());
         let loc = Tok.first_loc_of_file file in
-        let error = E.mk_error ~rule_id loc OutJ.Timeout in
+        let error = E.mk_error ~rule_id ~loc OutJ.Timeout in
         RP.mk_match_result []
           (Core_error.ErrorSet.singleton error)
           (Core_profiling.empty_rule_profiling rule)
@@ -202,7 +202,7 @@ let check
       Lazy.force lazy_ast_and_errors |> ignore
   | _else_ -> ());
 
-  let per_rule_boilerplate_fn = per_rule_boilerplate_fn timeout !!file in
+  let per_rule_boilerplate_fn = per_rule_boilerplate_fn timeout file in
 
   (* We separate out the taint rules specifically, because we may want to
      do some rule-wide optimizations, which require analyzing more than
diff --git a/src/engine/Match_tainting_mode.ml b/src/engine/Match_tainting_mode.ml
index 3dd59fcbae81..3886b67aad97 100644
--- a/src/engine/Match_tainting_mode.ml
+++ b/src/engine/Match_tainting_mode.ml
@@ -233,14 +233,15 @@ let check_var_def lang options taint_config env id ii expr =
     |> G.e |> G.exprstmt
   in
   let xs = AST_to_IL.stmt lang assign in
-  let flow = CFG_build.cfg_of_stmts xs in
+  let cfg, lambdas = CFG_build.cfg_of_stmts xs in
   let effects, end_mapping =
     (* There could be taint effects indeed, e.g. if 'expr' is `sink(taint)`. *)
     let java_props_cache = D.mk_empty_java_props_cache () in
     Dataflow_tainting.fixpoint ~in_env:env lang options taint_config
-      java_props_cache flow
+      java_props_cache
+      IL.{ params = []; cfg; lambdas }
   in
-  let out_env = end_mapping.(flow.exit).Dataflow_core.out_env in
+  let out_env = end_mapping.(cfg.exit).Dataflow_core.out_env in
   let lval : IL.lval = { base = Var name; rev_offset = [] } in
   let xtaint = Lval_env.find_lval_xtaint out_env lval in
   (xtaint, effects)
@@ -382,16 +383,16 @@ let check_fundef lang options taint_config opt_ent ctx ?glob_env
     Some (IL.str_of_name name)
   in
   let fdef = AST_to_IL.function_definition lang ~ctx fdef in
-  let IL.{ fcfg = flow; _ } = CFG_build.cfg_of_fdef lang fdef in
+  let fcfg = CFG_build.cfg_of_fdef fdef in
   let in_env, env_effects =
     mk_fun_input_env lang options taint_config ?glob_env fdef
   in
   let effects, mapping =
     Dataflow_tainting.fixpoint ~in_env ?name lang options taint_config
-      java_props_cache flow
+      java_props_cache fcfg
   in
   let effects = Effects.union env_effects effects in
-  (flow, effects, mapping)
+  (fcfg, effects, mapping)
 
 let check_rule per_file_formula_cache (rule : R.taint_rule) match_hook
     (xconf : Match_env.xconfig) (xtarget : Xtarget.t) =
@@ -442,7 +443,7 @@ let check_rule per_file_formula_cache (rule : R.taint_rule) match_hook
   (* FIXME: This is no longer needed, now we can just check the type 'n'. *)
   let ctx = ref AST_to_IL.empty_ctx in
   Visit_function_defs.visit
-    (fun opt_ent _ ->
+    (fun opt_ent _fdef ->
       match opt_ent with
       | Some { name = EN (Id (n, _)); _ } ->
           ctx := AST_to_IL.add_entity_name !ctx n
@@ -473,10 +474,11 @@ let check_rule per_file_formula_cache (rule : R.taint_rule) match_hook
         |> G.stmt1
       in
       let stmts = AST_to_IL.stmt lang fields in
-      let flow = CFG_build.cfg_of_stmts stmts in
+      let cfg, lambdas = CFG_build.cfg_of_stmts stmts in
       let init_effects, _mapping =
         Dataflow_tainting.fixpoint lang xconf.config taint_config
-          java_props_cache flow
+          java_props_cache
+          IL.{ params = []; cfg; lambdas }
       in
       record_matches init_effects)
     ast;
@@ -488,10 +490,11 @@ let check_rule per_file_formula_cache (rule : R.taint_rule) match_hook
   let (), match_time =
     Common.with_time (fun () ->
         let xs = AST_to_IL.stmt lang (G.stmt1 ast) in
-        let flow = CFG_build.cfg_of_stmts xs in
+        let cfg, lambdas = CFG_build.cfg_of_stmts xs in
         let top_effects, _mapping =
           Dataflow_tainting.fixpoint lang xconf.config taint_config
-            java_props_cache flow
+            java_props_cache
+            IL.{ params = []; cfg; lambdas }
         in
         record_matches top_effects)
   in
diff --git a/src/engine/Match_tainting_mode.mli b/src/engine/Match_tainting_mode.mli
index f0bc20489599..65b556d2feb5 100644
--- a/src/engine/Match_tainting_mode.mli
+++ b/src/engine/Match_tainting_mode.mli
@@ -41,7 +41,7 @@ val check_fundef :
   ?glob_env:Taint_lval_env.t ->
   Dataflow_tainting.java_props_cache ->
   AST_generic.function_definition ->
-  IL.cfg * Shape_and_sig.Effects.t * Dataflow_tainting.mapping
+  IL.fun_cfg * Shape_and_sig.Effects.t * Dataflow_tainting.mapping
 (** Check a function definition using a [Dataflow_tainting.config] (which can
   * be obtained with [taint_config_of_rule]). Findings are passed on-the-fly
   * to the [handle_findings] callback in the dataflow config.
diff --git a/src/il/Display_IL.ml b/src/il/Display_IL.ml
index 5570b0230970..1f36f3a92c87 100644
--- a/src/il/Display_IL.ml
+++ b/src/il/Display_IL.ml
@@ -71,16 +71,6 @@ let rec string_of_exp_kind e =
 
 and string_of_exp e = string_of_exp_kind e.e
 
-let string_of_name_param { pname; pdefault } =
-  let default_str = if Option.is_some pdefault then " = <EXPR>" else "" in
-  Common.spf "%s%s" (str_of_name pname) default_str
-
-let string_of_param param =
-  match param with
-  | Param name_param -> string_of_name_param name_param
-  | PatternParam _pat -> "<PATTERN>"
-  | FixmeParam -> "<FIXME>"
-
 let string_of_argument arg =
   match arg with
   | Unnamed e
@@ -97,20 +87,16 @@ let short_string_of_node_kind nkind =
   | TrueNode e -> Common.spf "<TRUE %s>" (string_of_exp e)
   | FalseNode e -> Common.spf "<FALSE %s>" (string_of_exp e)
   | Join -> "<join>"
-  | OtherJoin -> "<other join>"
   | NCond _ -> "cond(...)"
   | NGoto (_, l) -> "goto " ^ str_of_label l
   | NReturn (_, e) -> Common.spf "return %s" (string_of_exp e)
   | NThrow _ -> "throw ...;"
-  | NLambda params ->
-      let params_strs = List_.map string_of_param params in
-      "LAMBDA " ^ String.concat ", " params_strs
   | NOther (Noop str) -> Common.spf "<noop: %s>" str
   | NOther _ -> "<other>"
   | NInstr x -> (
       match x.i with
       | Assign (lval, exp) -> string_of_lval lval ^ " = " ^ string_of_exp exp
-      | AssignAnon _ -> " ... = <lambda|class>"
+      | AssignAnon (lval, _) -> string_of_lval lval ^ " = " ^ "<lambda|class>"
       | Call (lval_opt, exp, args) ->
           let lval_str =
             match lval_opt with
diff --git a/src/il/IL.ml b/src/il/IL.ml
index 52621e953848..3cf54f2c2317 100644
--- a/src/il/IL.ml
+++ b/src/il/IL.ml
@@ -115,6 +115,14 @@ let compare_name name1 name2 =
   | 0 -> String.compare str1 str2
   | cmp -> cmp
 
+module NameOrdered = struct
+  type t = name
+
+  let compare = compare_name
+end
+
+module NameMap = Map.Make (NameOrdered)
+
 (*****************************************************************************)
 (* Fixme constructs *)
 (*****************************************************************************)
@@ -444,27 +452,12 @@ and node_kind =
    * condition as in 'NCond'. *)
   | TrueNode of exp (* same as in Cond *)
   | FalseNode of exp (* same as in Cond *)
-  (* Join is a type of join that follows NCond only,
-   * while OtherJoin follows other nodes (e.g., NInstr, NLambda) but not NCond.
-   *
-   * we need the two kinds of joins because not only do we add joins
-   * after NCond, we also add joins after NInstr/ NLamdba for handling
-   * the insertion of the lambda CFG (see cfg_stmt, cfg_lambda)
-   * and the translation of the set of lambdas used in a node
-   * (see build_cfg_for_lambdas_in). and in some cases, we want to
-   * have a clear distinction between the two uses.
-   *
-   * in general, we can treat Join and OtherJoin as the same, unless
-   * we need to separate out the conditional case, such as in Dataflow_when.ml.
-   *)
   | Join
-  | OtherJoin
   | NInstr of instr
   | NCond of tok * exp
   | NGoto of tok * label
   | NReturn of tok * exp
   | NThrow of tok * exp
-  | NLambda of param list (* just the params, the body nodes follow this one *)
   | NOther of other_stmt
   | NTodo of stmt
 [@@deriving
@@ -476,7 +469,9 @@ and node_kind =
  *)
 type edge = Direct
 type cfg = (node, edge) CFG.t
-type fdef_cfg = { fparams : param list; fcfg : cfg }
+
+type fun_cfg = { params : param list; cfg : cfg; lambdas : lambdas_cfgs }
+and lambdas_cfgs = fun_cfg NameMap.t
 
 (* an int representing the index of a node in the graph *)
 type nodei = Ograph_extended.nodei
diff --git a/src/il/IL_helpers.ml b/src/il/IL_helpers.ml
index c1ca8bd9d6a6..a8df796a27d2 100644
--- a/src/il/IL_helpers.ml
+++ b/src/il/IL_helpers.ml
@@ -13,6 +13,7 @@
  * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the file
  * LICENSE for more details.
  *)
+open Common
 open IL
 
 (*****************************************************************************)
@@ -125,15 +126,13 @@ let rlvals_of_node = function
   | TrueNode _
   | FalseNode _
   | NGoto _
-  | Join
-  | OtherJoin ->
+  | Join ->
       []
   | NInstr x -> rlvals_of_instr x
   | NCond (_, e)
   | NReturn (_, e)
   | NThrow (_, e) ->
       lvals_of_exp e
-  | NLambda _
   | NOther _
   | NTodo _ ->
       []
@@ -151,14 +150,27 @@ let orig_of_node = function
   | NInstr i -> Some i.iorig
   | NGoto _
   | Join
-  | OtherJoin
-  | NLambda _
   | NOther _
   | NTodo _ ->
       None
 
-module NameOrdered = struct
-  type t = name
+let lval_is_lambda lambdas_cfgs lval =
+  match lval with
+  | { base = Var name; rev_offset = [] } ->
+      let* lambda_cfg = IL.NameMap.find_opt name lambdas_cfgs in
+      Some (name, lambda_cfg)
+  | { base = Var _ | VarSpecial _ | Mem _; rev_offset = _ } ->
+      (* Lambdas are only assigned to plain variables without any offset. *)
+      None
 
-  let compare = IL.compare_name
-end
+let rec reachable_nodes fun_cfg =
+  let get_reachable_nodes cfg =
+    cfg.CFG.reachable |> CFG.NodeiSet.to_seq
+    |> Seq.map cfg.CFG.graph#nodes#assoc
+  in
+  let main_nodes = get_reachable_nodes fun_cfg.cfg in
+  let lambdas_nodes =
+    fun_cfg.lambdas |> NameMap.to_seq
+    |> Seq.map (fun (_lname, lcfg) -> reachable_nodes lcfg)
+  in
+  Seq.concat (Seq.cons main_nodes lambdas_nodes)
diff --git a/src/il/IL_helpers.mli b/src/il/IL_helpers.mli
index feeb396e3fb7..d219c4ab7ace 100644
--- a/src/il/IL_helpers.mli
+++ b/src/il/IL_helpers.mli
@@ -23,9 +23,8 @@ val rlvals_of_node : IL.node_kind -> IL.lval list
 
 val orig_of_node : IL.node_kind -> IL.orig option
 
-(** Useful to instantiate data strutures like Map and Set. *)
-module NameOrdered : sig
-  type t = IL.name
+val lval_is_lambda : IL.lambdas_cfgs -> IL.lval -> (IL.name * IL.fun_cfg) option
+(** Lookup an 'lval' in a 'lambdas_cfgs' table to obtain the lambda's CFG. *)
 
-  val compare : t -> t -> int
-end
+val reachable_nodes : IL.fun_cfg -> IL.node Seq.t
+(** Get the reachable nodes from function's CFG, including the nodes in the lambdas' CFGs. *)
diff --git a/src/main/dune b/src/main/dune
index eaa4dd640825..5975a0159c53 100644
--- a/src/main/dune
+++ b/src/main/dune
@@ -1,39 +1,55 @@
 (executables
  (names Main)
  (flags
-   (:standard (:include flags.sexp))
-   (:standard -open TCB -open Commons_TCB)
-   )
+  (:standard
+   (:include flags.sexp))
+  (:standard -open TCB -open Commons_TCB))
  (libraries
-    TCB
-    semgrep.core_cli
-    semgrep.osemgrep_cli
-
-    ; implementations of dune virtual modules
-    lwt_platform.unix
-    tracing.unix
- )
- (preprocess (pps ppx_profiling))
-  ; 'byte' is for ocamldebug
-  (modes native byte)
-)
-
+  TCB
+  semgrep.core_cli
+  semgrep.osemgrep_cli
+  ; implementations of dune virtual modules
+  lwt_platform.unix
+  tracing.unix)
+ (preprocess
+  (pps ppx_profiling))
+ ; 'byte' is for ocamldebug
+ (modes native byte))
 
 ; use flags.sh to generate the OS specific build flags
+
 (rule
  (targets flags.sexp)
- (enabled_if (<> %{os_type} Win32))
+ (deps
+  (:langs tree-sitter-langs.dat))
+ (enabled_if
+  (<> %{os_type} Win32))
  (action
-  (with-stdout-to flags.sexp
-   (run ./flags.sh %{ocaml-config:system}))))
+  (with-stdout-to
+   flags.sexp
+   (run ./flags.sh %{ocaml-config:system} %{langs}))))
 
 ; flags.sh won't run properly in Windows, so simply hardcode to ( :standard )
 ; todo: is there an easy way to do static compilation in Windows?
 ; todo: is there a way we could make flags.sh more portable?
+
 (rule
  (targets flags.sexp)
- (enabled_if (= %{os_type} Win32))
- (action (write-file %{targets} "( )")))
+ (enabled_if
+  (= %{os_type} Win32))
+ (action
+  (write-file %{targets} "( )")))
+
+(rule
+ (deps
+  (source_tree %{project_root}/languages))
+ (targets tree-sitter-langs.dat)
+ (action
+  (with-stdout-to
+   tree-sitter-langs.dat
+   (chdir
+    %{project_root}
+    (run src/main/list-tree-sitter-langs.sh)))))
 
 (install
  (package semgrep)
@@ -46,6 +62,4 @@
   (Main.exe as semgrep)
   ; useful to debug semgrep, see ../../debug and ocamldebug
   (Main.bc as semgrep-core.bc)
-  (Main.bc as osemgrep.bc)
- )
-)
+  (Main.bc as osemgrep.bc)))
diff --git a/src/main/flags.sh b/src/main/flags.sh
index 7999a68d0822..97c2fb9c8f2a 100755
--- a/src/main/flags.sh
+++ b/src/main/flags.sh
@@ -6,15 +6,8 @@
 # =======================
 #
 # When adding a new dependency, you may need to update this file. This is
-# required when additional objects must be linked. Examples include: a new
-# language using tree-sitter, a package with C bindings or system dependency.
-#
-# - If you are adding a new LANGUAGE (using tree-sitter):
-#
-#   - In the macosx case below, add the language name in the LANGS array.
-#
-#   - Other cases should not typically require changes.
-#
+# required when additional objects must be linked. Examples include: a
+# package with C bindings or system dependency.
 #
 # - If you are adding a new OCAML PACKAGE:
 #
@@ -39,6 +32,7 @@
 set -eu
 
 OS="$1"
+TREE_SITTER_LANGS="$2"
 
 >&2 echo "Generating linking flags for OS ${OS} (!!!in case of linking errors, adjust src/main/flags.sh!!!)"
 
@@ -120,44 +114,7 @@ else
     # below (see, e.g., gmp). If we expect it to always be installed via brew,
     # then use `brew --prefix <BREW PACKAGE NAME>`.
     macosx)
-        # TODO: it would be best if this could be easily autogenerated from
-        # languages/
-        LANGS=(
-            "bash"
-            "c_sharp"
-            # we would have c here, but now that we use the cpp parser for c instead, we
-            # no longer have the c code. so we cannot rely on the existence of the tree_sitter_c stubs
-            # "c"
-            "cairo"
-            "circom"
-            "clojure"
-            "cpp"
-            "dart"
-            "dockerfile"
-            "go"
-            "hack"
-            "hcl"
-            "html"
-            "java"
-            "jsonnet"
-            "julia"
-            "kotlin"
-            "lua"
-            "move_on_sui"
-            "move_on_aptos"
-            "ocaml"
-            "php"
-            "promql"
-            "proto"
-            "python"
-            "ql"
-            "r"
-            "ruby"
-            "rust"
-            "solidity"
-            "swift"
-            "tsx"
-            "typescript")
+        langs=$(cat "${TREE_SITTER_LANGS}")
         FLAGS=("-noautolink")
         CCOPT=()
         # TODO: ideally much of this could be generated from dune/opam.
@@ -213,7 +170,7 @@ else
             CCLIB+=("${SEMGREP_LIBEV_ARCHIVE_PATH}")
         fi
 
-        for lang in ${LANGS[@]+"${LANGS[@]}"}; do
+        for lang in $langs; do
             CCLIB+=("-ltree_sitter_${lang}_stubs")
         done
         ;;
diff --git a/src/main/list-tree-sitter-langs.sh b/src/main/list-tree-sitter-langs.sh
new file mode 100755
index 000000000000..c18da6963da0
--- /dev/null
+++ b/src/main/list-tree-sitter-langs.sh
@@ -0,0 +1,18 @@
+#! /usr/bin/env bash
+#
+# List of tree-sitter parsers into a config file
+# This runs from the project root and looks for tree-sitter parsers
+# in the standard locations.
+#
+set -eu
+
+# Paths are relative to the project root which can be
+# semgrep, semgrep-proprietary, or semgrep-proprietary/OSS depending
+# on context.
+paths=$(echo languages/*/tree-sitter/semgrep-*)
+
+# Extract 'some_lang' from folders named 'semgrep-some-lang'
+for path in $paths; do
+  semgrep_lang=$(basename "$path")
+  echo "${semgrep_lang#semgrep-}" | tr '-' '_' | sort
+done
diff --git a/src/matching/Apply_equivalences.ml b/src/matching/Apply_equivalences.ml
index 829434badcc5..9dee709f6a06 100644
--- a/src/matching/Apply_equivalences.ml
+++ b/src/matching/Apply_equivalences.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2019-2021 r2c
+ * Copyright (C) 2019-2021 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
diff --git a/src/matching/Generic_vs_generic.ml b/src/matching/Generic_vs_generic.ml
index 8c35e45ebded..8b3be5b2361a 100644
--- a/src/matching/Generic_vs_generic.ml
+++ b/src/matching/Generic_vs_generic.ml
@@ -590,20 +590,21 @@ and m_name a b =
   *)
   | _ when dotted_contains_mvars (H.dotted_ident_of_name a) -> fail () tin
   (* coupling: resolved names with wildcards
-     If the name we are trying to match has a resolved name, then its "true name" is
-     something which we already know. We don't need to patch it with the information
-     from wildcard imports to try and match it.
-
-     This also means that any time in matching that we find ourselves matching something
-     with a resolved name, we have to ensure any future calls to `m_name` do not unpack
-     wildcard imports. This case here is insufficient, because for instance if we were
-     matching `B.foo` to `foo` with a resolved name of `A.foo`, one valid code path is
-     to try and match `foo` with no resolved name.
-
-     This will cause the wildcard import logic to trigger, despite the fact that this
-     `foo` identifier had a resolved name, because we got rid of it. So anywhere we
-     decompose on an `ImportedEntity` or similar, we should wipe wildcard imports from
-     `tin` to prevent this case.
+     If the name we are trying to match has a resolved name, then its
+     "true name" is something which we already know. We don't need to patch it
+     with the information from wildcard imports to try and match it.
+
+     This also means that any time in matching that we find ourselves matching
+     something with a resolved name, we have to ensure any future calls to
+     `m_name` do not unpack wildcard imports. This case here is insufficient,
+     because for instance if we were matching `B.foo` to `foo` with a resolved
+     name of `A.foo`, one valid code path is to try and match `foo` with no
+     resolved name.
+
+     This will cause the wildcard import logic to trigger, despite the fact
+     that this `foo` identifier had a resolved name, because we got rid of it.
+     So anywhere we decompose on an `ImportedEntity` or similar, we should wipe
+     wildcard imports from `tin` to prevent this case.
   *)
   (* For this case, we can just fail here, because this will be taken care of by
      the main `m_name_inner` call.
@@ -821,14 +822,22 @@ and m_expr ?(is_root = false) ?(arguments_have_changed = true) a b =
   | _, G.Alias (_alias, b1) -> m_expr a b1
   (* equivalence: user-defined equivalence! *)
   | G.DisjExpr (a1, a2), _b -> m_expr a1 b >||> m_expr a2 b
+  | G.LocalImportAll (a0, a1, a2), B.LocalImportAll (b0, b1, b2) ->
+      let* () = m_module_name a0 b0 in
+      let* () = m_tok a1 b1 in
+      m_expr a2 b2
+  | _, G.LocalImportAll (b0, _b1, b2) -> (
+      match b0 with
+      | G.DottedName bn -> with_additional_wildcard_import bn (m_expr a b2)
+      | G.FileName _ -> fail ())
   (* This case should only run exactly once.
      This is so we do not endlessly loop trying to match to the same two things.
-     By setting `arguments_have_changed` to false, we ensure that we fall-through to
-     the cases that do decompose on `a` or `b`.
+     By setting `arguments_have_changed` to false, we ensure that we
+     fall-through to the cases that do decompose on `a` or `b`.
   *)
   | _, G.Cast (_, _, b1) when arguments_have_changed ->
-      (* We apply this equivalence only if not at the root, meaning we've done work
-         to get here, and should consider all possibilities.
+      (* We apply this equivalence only if not at the root, meaning we've done
+         work to get here, and should consider all possibilities.
          This is similar to symbolic propagation.
       *)
       (if not is_root then m_expr a b1 else fail ())
@@ -1163,6 +1172,7 @@ and m_expr ?(is_root = false) ?(arguments_have_changed = true) a b =
   | G.Seq _, _
   | G.Ref _, _
   | G.DeRef _, _
+  | G.LocalImportAll _, _
   | G.StmtExpr _, _
   | G.OtherExpr _, _
   | G.RawExpr _, _
diff --git a/src/matching/Matching_generic.ml b/src/matching/Matching_generic.ml
index 0f4c466e4fa8..dd3ddf52e3df 100644
--- a/src/matching/Matching_generic.ml
+++ b/src/matching/Matching_generic.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2019-2023 Semgrep Inc.
+ * Copyright (C) 2019-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
@@ -426,6 +426,11 @@ let wipe_wildcard_imports f tin =
   let tout = f { tin with wildcard_imports = [] } in
   tout |> List_.map (fun tin -> { tin with wildcard_imports })
 
+let with_additional_wildcard_import dotted f tin =
+  let wildcard_imports = tin.wildcard_imports in
+  let tout = f { tin with wildcard_imports = dotted :: wildcard_imports } in
+  tout |> List_.map (fun tin -> { tin with wildcard_imports })
+
 (*****************************************************************************)
 (* Helpers *)
 (*****************************************************************************)
diff --git a/src/matching/Matching_generic.mli b/src/matching/Matching_generic.mli
index da2cd7c451ea..be7847af12d7 100644
--- a/src/matching/Matching_generic.mli
+++ b/src/matching/Matching_generic.mli
@@ -65,6 +65,11 @@ val environment_of_any : Lang.t -> Rule_options.t -> AST_generic.any -> tin
    wildcard imports in certain cases (resolved name matching).
 *)
 val wipe_wildcard_imports : (tin -> tout) -> tin -> tout
+
+(* to handle LocalImportAll *)
+val with_additional_wildcard_import :
+  AST_generic.dotted_ident -> (tin -> tout) -> tin -> tout
+
 val add_mv_capture : Metavariable.mvar -> Metavariable.mvalue -> tin -> tin
 
 (* Update the matching list of statements by providing a new matching
diff --git a/src/matching/Normalize_generic.ml b/src/matching/Normalize_generic.ml
index 457c59ff24aa..d54e36007d0d 100644
--- a/src/matching/Normalize_generic.ml
+++ b/src/matching/Normalize_generic.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2019-2021 r2c
+ * Copyright (C) 2019-2021 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
diff --git a/src/matching/SubAST_generic.ml b/src/matching/SubAST_generic.ml
index 2e080b558eff..f35de526029c 100644
--- a/src/matching/SubAST_generic.ml
+++ b/src/matching/SubAST_generic.ml
@@ -1,6 +1,6 @@
 (* Yoann Padioleau
  *
- * Copyright (C) 2019-2022 r2c
+ * Copyright (C) 2019-2022 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 AST_generic
+module Log = Log_matching.Log
 module H = AST_generic_helpers
 
 (*****************************************************************************)
@@ -132,6 +133,8 @@ let subexprs_of_expr with_symbolic_propagation e =
   | Cast (_, _, e)
   | Ref (_, e)
   | DeRef (_, e)
+  | Alias (_, e)
+  | LocalImportAll (_, _, e)
   | DeepEllipsis (_, e, _)
   | DotAccessEllipsis (e, _) ->
       [ e ]
@@ -166,7 +169,6 @@ let subexprs_of_expr with_symbolic_propagation e =
       (* in theory we should go deeper in any *)
       subexprs_of_any_list anys
   | RawExpr x -> Raw_tree.anys x |> subexprs_of_any_list
-  | Alias (_, e1) -> [ e1 ]
   | Lambda def -> subexprs_of_stmt (H.funcbody_to_stmt def.fbody)
   | Xml { xml_attrs; xml_body; _ } ->
       List_.filter_map
@@ -203,7 +205,8 @@ let subexprs_of_expr ?(symbolic_propagation = false) e =
  * but not necessarily any expressions like 'bar() || foo();'.
  * See tests/ts/deep_exprtmt.ts for more examples.
  *)
-let subexprs_of_expr_implicit with_symbolic_propagation e =
+let subexprs_of_expr_implicit (with_symbolic_propagation : bool) (e : expr) :
+    expr list =
   match e.e with
   | N (Id (_, { id_svalue = { contents = Some (Sym e1) }; _ }))
     when with_symbolic_propagation ->
@@ -262,6 +265,7 @@ let subexprs_of_expr_implicit with_symbolic_propagation e =
   | OtherExpr (_, _anys) -> []
   | RawExpr _ -> []
   | Alias (_, _e1) -> []
+  | LocalImportAll (_, _, _e1) -> []
   | Xml _xmlbody -> []
   | Constructor _ -> []
   | RegexpTemplate _ -> []
@@ -272,6 +276,7 @@ let subexprs_of_expr_implicit with_symbolic_propagation e =
   | DeepEllipsis _
   | DotAccessEllipsis _
   | DisjExpr _ ->
+      Log.err (fun m -> m "%s: impossible AST: %s" __FUNCTION__ (show_expr e));
       raise Common.Impossible
 [@@profiling]
 
diff --git a/src/metachecking/Check_rule.ml b/src/metachecking/Check_rule.ml
index d3b6725b84c5..938a4cc48db6 100644
--- a/src/metachecking/Check_rule.ml
+++ b/src/metachecking/Check_rule.ml
@@ -66,10 +66,15 @@ exception No_metacheck_file of string
 (*****************************************************************************)
 
 let error (rule : Rule.t) (t : Tok.t) (s : string) : Core_error.t =
-  let loc = Tok.unsafe_loc_of_tok t in
+  let loc =
+    match Tok.loc_of_tok t with
+    | Ok x -> Some x
+    (* TODO? Logs? *)
+    | Error _ -> None
+  in
   let _check_idTODO = "semgrep-metacheck-builtin" in
   let rule_id, _ = rule.id in
-  E.mk_error ~rule_id ~msg:s loc OutJ.SemgrepMatchFound
+  E.mk_error ~rule_id ~msg:s ?loc OutJ.SemgrepMatchFound
 
 (*****************************************************************************)
 (* Checks *)
@@ -256,7 +261,7 @@ let semgrep_check (caps : Core_scan.caps) (metachecks : Fpath.t)
     let s = m.rule_id.message in
     let _check_id = m.rule_id.id in
     (* TODO: why not set ~rule_id here?? bug? *)
-    E.mk_error ~msg:s loc OutJ.SemgrepMatchFound
+    E.mk_error ~msg:s ~loc OutJ.SemgrepMatchFound
   in
   (* LATER: what if the rule is written in Jsonnet or JSON ? *)
   let lang : Lang.t = Yaml in
@@ -311,10 +316,10 @@ let run_checks (caps : Core_scan.caps) (metachecks : Fpath.t)
                (* TODO this error is special cased because YAML files that
                   aren't semgrep rules are getting scanned *)
                | Error ({ kind = InvalidYaml _; _ } : Rule_error.t) -> []
-               | Error e -> [ Core_error.error_of_rule_error file e ]
+               | Error e -> [ Core_error.error_of_rule_error e ]
                | exception exn ->
                    let e = Exception.catch exn in
-                   [ E.exn_to_error None file e ])
+                   [ E.exn_to_error ~file e ])
       in
       semgrep_found_errs @ ocaml_found_errs
 
diff --git a/src/metachecking/Unit_metachecking.ml b/src/metachecking/Unit_metachecking.ml
index 30636c726161..56d17bdcfa7a 100644
--- a/src/metachecking/Unit_metachecking.ml
+++ b/src/metachecking/Unit_metachecking.ml
@@ -40,7 +40,7 @@ let metachecker_checks_tests () =
             t (Fpath.basename file) (fun () ->
                 let actual =
                   match Parse_rule.parse file with
-                  | Error e -> [ Core_error.error_of_rule_error file e ]
+                  | Error e -> [ Core_error.error_of_rule_error e ]
                   | Ok rules ->
                       rules
                       |> List.concat_map (fun rule -> Check_rule.check rule)
diff --git a/src/osemgrep/cli_ci/Ci_CLI.ml b/src/osemgrep/cli_ci/Ci_CLI.ml
index 7fda75e5d218..a3c420007004 100644
--- a/src/osemgrep/cli_ci/Ci_CLI.ml
+++ b/src/osemgrep/cli_ci/Ci_CLI.ml
@@ -142,6 +142,12 @@ let o_x_dump_n_rule_partitions : int Term.t =
   let info = Arg.info [ "x-dump-rule-partitions" ] ~doc:{|Internal flag.|} in
   Arg.value (Arg.opt Arg.int 0 info)
 
+let o_x_dump_rule_partitions_dir : string Term.t =
+  let info =
+    Arg.info [ "x-dump-rule-partitions-dir" ] ~doc:{|Internal flag.|}
+  in
+  Arg.value (Arg.opt Arg.string "" info)
+
 let o_x_partial_config : string Term.t =
   let info = Arg.info [ "x-partial-config" ] ~doc:{|Internal flag.|} in
   Arg.value (Arg.opt Arg.string "" info)
@@ -165,13 +171,13 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
       _dump_command_for_core emacs emacs_outputs exclude_ exclude_minified_files
       exclude_rule_ids experimental_requirements_lockfiles files_with_matches
       force_color gitlab_sast gitlab_sast_outputs gitlab_secrets
-      gitlab_secrets_outputs _historical_secrets include_ incremental_output
-      json json_outputs junit_xml junit_xml_outputs matching_explanations
-      max_chars_per_line max_lines_per_finding max_log_list_entries
-      max_memory_mb max_target_bytes metrics num_jobs no_secrets_validation
-      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
+      gitlab_secrets_outputs _historical_secrets ignore_semgrepignore_files
+      include_ incremental_output json json_outputs junit_xml junit_xml_outputs
+      matching_explanations max_chars_per_line max_lines_per_finding
+      max_log_list_entries max_memory_mb max_target_bytes metrics num_jobs
+      no_secrets_validation 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 =
     let output_format : Output_format.t =
@@ -239,6 +245,7 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
         always_select_explicit_targets = scan_unknown_extensions;
         explicit_targets = Find_targets.Explicit_targets.empty;
         respect_gitignore;
+        respect_semgrepignore_files = not ignore_semgrepignore_files;
         exclude_minified_files;
       }
     in
@@ -286,9 +293,10 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
         show = None;
         validate = None;
         test = None;
-        ls = false;
         experimental_requirements_lockfiles;
         allow_dynamic_dependency_resolution;
+        ls = false;
+        ls_format = Ls_subcommand.default_format;
       }
   in
   (* Term defines 'const' but also the '$' operator *)
@@ -303,18 +311,19 @@ let scan_subset_cmdline_term : Scan_CLI.conf Term.t =
     $ SC.o_experimental_requirements_lockfiles $ SC.o_files_with_matches
     $ SC.o_force_color $ SC.o_gitlab_sast $ SC.o_gitlab_sast_outputs
     $ SC.o_gitlab_secrets $ SC.o_gitlab_secrets_outputs
-    $ SC.o_historical_secrets $ SC.o_include $ SC.o_incremental_output
-    $ SC.o_json $ SC.o_json_outputs $ SC.o_junit_xml $ SC.o_junit_xml_outputs
-    $ SC.o_matching_explanations $ SC.o_max_chars_per_line
-    $ SC.o_max_lines_per_finding $ SC.o_max_log_list_entries
-    $ SC.o_max_memory_mb $ SC.o_max_target_bytes $ SC.o_metrics $ SC.o_num_jobs
-    $ SC.o_no_secrets_validation $ 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_version_check $ SC.o_vim $ SC.o_vim_outputs)
+    $ SC.o_historical_secrets $ SC.o_ignore_semgrepignore_files $ SC.o_include
+    $ SC.o_incremental_output $ SC.o_json $ SC.o_json_outputs $ SC.o_junit_xml
+    $ SC.o_junit_xml_outputs $ SC.o_matching_explanations
+    $ SC.o_max_chars_per_line $ SC.o_max_lines_per_finding
+    $ SC.o_max_log_list_entries $ SC.o_max_memory_mb $ SC.o_max_target_bytes
+    $ SC.o_metrics $ SC.o_num_jobs $ SC.o_no_secrets_validation $ 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_version_check $ SC.o_vim $ SC.o_vim_outputs)
 
 (*************************************************************************)
 (* Turn argv into conf *)
@@ -327,8 +336,9 @@ let cmdline_term : conf Term.t =
    * variables (Romain's idea).
    *)
   let combine scan_conf audit_on code secrets dry_run _internal_ci_scan_results
-      _x_dump_n_rule_partitions _x_partial_config _x_partial_output subdir
-      supply_chain suppress_errors _git_meta _github_meta =
+      _x_dump_n_rule_partitions _x_dump_rule_partitions_dir _x_partial_config
+      _x_partial_output subdir supply_chain suppress_errors _git_meta
+      _github_meta =
     let products =
       (if secrets then [ `Secrets ] else [])
       @ (if code then [ `SAST ] else [])
@@ -339,9 +349,9 @@ let cmdline_term : conf Term.t =
   Term.(
     const combine $ scan_subset_cmdline_term $ o_audit_on $ o_code
     $ SC.o_secrets $ o_dry_run $ o_internal_ci_scan_results
-    $ o_x_dump_n_rule_partitions $ o_x_partial_config $ o_x_partial_output
-    $ o_subdir $ o_supply_chain $ o_suppress_errors $ Git_metadata.env
-    $ Github_metadata.env)
+    $ o_x_dump_n_rule_partitions $ o_x_dump_rule_partitions_dir
+    $ o_x_partial_config $ o_x_partial_output $ o_subdir $ o_supply_chain
+    $ o_suppress_errors $ Git_metadata.env $ Github_metadata.env)
 
 let doc = "the recommended way to run semgrep in CI"
 
diff --git a/src/osemgrep/cli_ci/Ci_subcommand.ml b/src/osemgrep/cli_ci/Ci_subcommand.ml
index e457c3092307..e52ebe418579 100644
--- a/src/osemgrep/cli_ci/Ci_subcommand.ml
+++ b/src/osemgrep/cli_ci/Ci_subcommand.ml
@@ -1,5 +1,5 @@
 open Common
-module OutJ = Semgrep_output_v1_j
+module Out = Semgrep_output_v1_j
 
 (*****************************************************************************)
 (* TODO: migrate this to the new scan endpoint to match the pysemgrep        *)
@@ -124,6 +124,15 @@ let exit_code_of_blocking_findings ~audit_mode ~on ~app_block_override
       Logs.app (fun m -> m "  No blocking findings so exiting with code 0");
       Exit_code.ok ~__LOC__
 
+let sanity_check_contributions (contribs : Out.contribution list) : unit =
+  match contribs with
+  | [] -> Logs.warn (fun m -> m "no contribution found from git log, weird")
+  | x :: _xs ->
+      Logs.debug (fun m ->
+          m "found %d contribs in the last 30 days, last is %s"
+            (List.length contribs)
+            (Out.string_of_contribution x))
+
 (*****************************************************************************)
 (* Scan config *)
 (*****************************************************************************)
@@ -144,7 +153,7 @@ let caps_with_token (token_opt : Auth.token option) caps =
 
 (* if something fails, we Error.exit_code_exn *)
 let deployment_config (caps : < Cap.network ; Auth.cap_token ; .. >) :
-    OutJ.deployment_config =
+    Out.deployment_config =
   match Semgrep_App.get_deployment_from_token caps with
   | None ->
       Logs.app (fun m ->
@@ -156,7 +165,7 @@ let deployment_config (caps : < Cap.network ; Auth.cap_token ; .. >) :
   | Some deployment_config ->
       Logs.debug (fun m ->
           m "received deployment = %s"
-            (OutJ.show_deployment_config deployment_config));
+            (Out.show_deployment_config deployment_config));
       deployment_config
 
 (* eventually output the origin (if the semgrep_url is not semgrep.dev) *)
@@ -188,17 +197,17 @@ let decode_json_rules caps (data : string) : Rule_fetching.rules_and_origin =
           failwith "impossible: received an invalid rule from CI")
 
 let scan_config_and_rules_from_deployment ~dry_run
-    (prj_meta : OutJ.project_metadata)
+    (prj_meta : Out.project_metadata)
     (caps : < Cap.network ; Auth.cap_token ; .. >)
-    (deployment_config : OutJ.deployment_config) :
-    Semgrep_App.scan_id * OutJ.scan_config * Rule_fetching.rules_and_origin list
+    (deployment_config : Out.deployment_config) :
+    Semgrep_App.scan_id * Out.scan_config * Rule_fetching.rules_and_origin list
     =
   Logs.app (fun m -> m "  %a" Fmt.(styled `Underline string) "CONNECTION");
   Logs.app (fun m ->
       m "  Reporting start of scan for %a"
         Fmt.(styled `Bold string)
         deployment_config.name);
-  let scan_metadata : OutJ.scan_metadata =
+  let scan_metadata : Out.scan_metadata =
     {
       cli_version = Version.version;
       unique_id = Uuidm.v4_gen (Stdlib.Random.State.make_self_init ()) ();
@@ -220,7 +229,7 @@ let scan_config_and_rules_from_deployment ~dry_run
       (* TODO: should be concatenated with the "Reporting start ..." *)
       Logs.app (fun m -> m " (scan_id=%s)" scan_id);
       (* TODO: set sca to metadata.is_sca_scan / supply_chain *)
-      let scan_config : OutJ.scan_config =
+      let scan_config : Out.scan_config =
         Logs.app (fun m ->
             m "  Fetching configuration from Semgrep Cloud Platform%a"
               at_url_maybe ());
@@ -321,7 +330,7 @@ let generate_meta_from_environment caps (baseline_ref : Digestif.SHA1.t option)
 (*****************************************************************************)
 (* Partition rules *)
 (*****************************************************************************)
-let finding_is_blocking (m : OutJ.cli_match) =
+let finding_is_blocking (m : Out.cli_match) =
   let contains_blocking xs =
     List.exists
       (function
@@ -330,7 +339,7 @@ let finding_is_blocking (m : OutJ.cli_match) =
       xs
   in
 
-  let validation_state_to_action (vs : OutJ.validation_state) =
+  let validation_state_to_action (vs : Out.validation_state) =
     match vs with
     | `Confirmed_valid -> "valid"
     | `Confirmed_invalid -> "invalid"
@@ -393,12 +402,12 @@ let partition_rules (filtered_rules : Rule.t list) =
   in
   (cai_rules, blocking_rules, non_blocking_rules)
 
-let partition_findings ~keep_ignored (results : OutJ.cli_match list) =
+let partition_findings ~keep_ignored (results : Out.cli_match list) =
   let groups =
     results
-    |> List.filter (fun (m : OutJ.cli_match) ->
+    |> List.filter (fun (m : Out.cli_match) ->
            Option.value ~default:false m.extra.is_ignored && not keep_ignored)
-    |> Assoc.group_by (fun (m : OutJ.cli_match) ->
+    |> Assoc.group_by (fun (m : Out.cli_match) ->
            if
              Common2.string_match_substring
                (Str.regexp "r2c-internal-cai")
@@ -456,9 +465,8 @@ let ord_of_severity (severity : Rule.severity) : int =
       4
   | `Critical -> 5
 
-let finding_of_cli_match _commit_date index (m : OutJ.cli_match) : OutJ.finding
-    =
-  let (r : OutJ.finding) =
+let finding_of_cli_match _commit_date index (m : Out.cli_match) : Out.finding =
+  let (r : Out.finding) =
     {
       check_id = m.check_id;
       path = m.path;
@@ -496,7 +504,7 @@ let finding_of_cli_match _commit_date index (m : OutJ.cli_match) : OutJ.finding
 (* Reporting *)
 (*****************************************************************************)
 
-let report_scan_environment (prj_meta : OutJ.project_metadata) : unit =
+let report_scan_environment (prj_meta : Out.project_metadata) : unit =
   Logs.app (fun m -> m "  %a" Fmt.(styled `Underline string) "SCAN ENVIRONMENT");
   Logs.app (fun m ->
       m "  versions    - semgrep %a on OCaml %a"
@@ -532,13 +540,15 @@ let report_scan_completed ~blocking_findings ~blocking_rules
 
 (* from scans.py *)
 let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
-    (caps : < Cap.exec >) (cli_output : OutJ.cli_output)
-    (rules : Rule.rule list) : OutJ.ci_scan_results * OutJ.ci_scan_complete =
+    (caps : < Cap.exec >) (cli_output : Out.cli_output) (rules : Rule.rule list)
+    : Out.ci_scan_results * Out.ci_scan_complete =
   let targets = cli_output.paths.scanned in
   let skipped = cli_output.paths.skipped in
 
   let rule_ids = rules |> List_.map (fun r -> fst r.Rule.id) in
   let contributions = Parse_contribution.get_contributions caps in
+  sanity_check_contributions contributions;
+
   (*
       we want date stamps assigned by the app to be assigned such that the
       current sort by relevant_since results in findings within a given scan
@@ -550,12 +560,12 @@ let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
       Int.compare (ord_of_severity a) (ord_of_severity b)
     in
     all_matches
-    |> List.sort (fun (m1 : OutJ.cli_match) (m2 : OutJ.cli_match) ->
+    |> List.sort (fun (m1 : Out.cli_match) (m2 : Out.cli_match) ->
            sort_severity m1.extra.severity m2.extra.severity)
   in
   let new_ignored, new_matches =
     all_matches
-    |> List.partition (fun (m : OutJ.cli_match) ->
+    |> List.partition (fun (m : Out.cli_match) ->
            Option.value ~default:false m.extra.is_ignored)
   in
   let findings = List_.mapi (finding_of_cli_match commit_date) new_matches in
@@ -571,7 +581,7 @@ let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
         | None -> Sys.getenv_opt "BITBUCKET_TOKEN" (* Bitbucket Cloud *))
   in
   (* POST to /api/agent/scans/<scan_id>/results *)
-  let results : OutJ.ci_scan_results =
+  let results : Out.ci_scan_results =
     {
       (* send a backup token in case the app is not available *)
       token = ci_token;
@@ -588,14 +598,13 @@ let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
   in
   if
     new_ignored
-    |> List.exists (fun (m : OutJ.cli_match) ->
-           m.extra.severity =*= `Experiment)
+    |> List.exists (fun (m : Out.cli_match) -> m.extra.severity =*= `Experiment)
   then
     Logs.app (fun m -> m "Some experimental rules were run during execution.");
 
   let ignored_ext_freqs =
     Option.value ~default:[] skipped
-    |> Assoc.group_by (fun (skipped_target : OutJ.skipped_target) ->
+    |> Assoc.group_by (fun (skipped_target : Out.skipped_target) ->
            Fpath.get_ext skipped_target.path)
     |> List.filter (fun (ext, _) -> not (String.equal ext ""))
     (* don't count files with no extension *)
@@ -603,7 +612,7 @@ let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
   in
 
   (* POST to /api/agent/scans/<scan_id>/complete *)
-  let complete : OutJ.ci_scan_complete =
+  let complete : Out.ci_scan_complete =
     {
       (* TODO: 'and not match.is_ignored for match in all_matches' *)
       exit_code = (if has_blocking_findings then 1 else 0);
@@ -653,9 +662,9 @@ let findings_and_complete ~has_blocking_findings ~commit_date ~engine_requested
 
 let upload_findings ~dry_run
     (caps : < Cap.network ; Auth.cap_token ; Cap.exec ; .. >)
-    (deployment_config : OutJ.deployment_config) (scan_id : Semgrep_App.scan_id)
-    (prj_meta : OutJ.project_metadata) blocking_findings filtered_rules
-    (cli_output : OutJ.cli_output) : Semgrep_App.app_block_override =
+    (deployment_config : Out.deployment_config) (scan_id : Semgrep_App.scan_id)
+    (prj_meta : Out.project_metadata) blocking_findings filtered_rules
+    (cli_output : Out.cli_output) : Semgrep_App.app_block_override =
   Logs.app (fun m -> m "  Uploading findings.");
   let results, complete =
     findings_and_complete
@@ -753,7 +762,7 @@ let run_conf (caps : caps) (ci_conf : Ci_CLI.conf) : Exit_code.t =
   (* TODO: we should use those fields! *)
   let {
     (* this is used in scan_config_and_rules_from_deployment *)
-    OutJ.rule_config = _;
+    Out.rule_config = _;
     (* those two fields do not matter; they should be in a separate
      * scan_response actually in the futur.
      *)
@@ -776,6 +785,7 @@ let run_conf (caps : caps) (ci_conf : Ci_CLI.conf) : Exit_code.t =
     enabled_products = _;
     triage_ignored_match_based_ids = _;
     triage_ignored_syntactic_ids = _;
+    path_to_transitivity = _;
   } =
     scan_config
   in
diff --git a/src/osemgrep/cli_ci/Ci_subcommand.mli b/src/osemgrep/cli_ci/Ci_subcommand.mli
index 469cce8f9bcd..7b033e0516a3 100644
--- a/src/osemgrep/cli_ci/Ci_subcommand.mli
+++ b/src/osemgrep/cli_ci/Ci_subcommand.mli
@@ -1,5 +1,3 @@
-module OutJ = Semgrep_output_v1_j
-
 type caps =
   < Cap.stdout
   ; Cap.network
@@ -20,5 +18,7 @@ val main : caps -> string array -> Exit_code.t
 
 (* internal *)
 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 : OutJ.cli_match -> bool
+val finding_is_blocking : Semgrep_output_v1_t.cli_match -> bool
diff --git a/src/osemgrep/cli_scan/Ls_subcommand.ml b/src/osemgrep/cli_scan/Ls_subcommand.ml
index af4bbd12d684..0af1e825e555 100644
--- a/src/osemgrep/cli_scan/Ls_subcommand.ml
+++ b/src/osemgrep/cli_scan/Ls_subcommand.ml
@@ -6,17 +6,29 @@
 open Common
 module OutJ = Semgrep_output_v1_j
 
-let run ~target_roots ~targeting_conf:conf () =
+(*
+   Print just the paths (like 'ls') or print a bunch of details (like 'ls -l')
+*)
+type format = Paths_only | Long [@@deriving show]
+
+let default_format = Paths_only
+
+let run ~target_roots ~targeting_conf:conf ~format () =
   let selected, skipped = Find_targets.get_target_fpaths conf target_roots in
   selected |> List.sort Fpath.compare
   |> List.iter (fun (x : Fpath.t) ->
-         UConsole.print (spf "selected %s" (x |> Fpath.to_string)));
-  skipped
-  |> List.sort (fun (a : OutJ.skipped_target) (b : OutJ.skipped_target) ->
-         Fpath.compare a.path b.path)
-  |> List.iter (fun (x : OutJ.skipped_target) ->
-         UConsole.print
-           (spf "ignored %s [%s]" (Fpath.to_string x.path)
-              (x.reason |> OutJ.string_of_skip_reason
-             |> JSON.remove_enclosing_quotes_of_jstring)));
+         match format with
+         | Paths_only -> UConsole.print (Fpath.to_string x)
+         | Long -> UConsole.print (spf "selected %s" (x |> Fpath.to_string)));
+  (match format with
+  | Paths_only -> ()
+  | Long ->
+      skipped
+      |> List.sort (fun (a : OutJ.skipped_target) (b : OutJ.skipped_target) ->
+             Fpath.compare a.path b.path)
+      |> List.iter (fun (x : OutJ.skipped_target) ->
+             UConsole.print
+               (spf "ignored %s [%s]" (Fpath.to_string x.path)
+                  (x.reason |> OutJ.string_of_skip_reason
+                 |> JSON.remove_enclosing_quotes_of_jstring))));
   Exit_code.ok ~__LOC__
diff --git a/src/osemgrep/cli_scan/Ls_subcommand.mli b/src/osemgrep/cli_scan/Ls_subcommand.mli
index cdfc80ac2fec..f61cfd359209 100644
--- a/src/osemgrep/cli_scan/Ls_subcommand.mli
+++ b/src/osemgrep/cli_scan/Ls_subcommand.mli
@@ -9,11 +9,19 @@
    turn it into a proper 'semgrep ls' or 'semgrep show targets' subcommand.
 *)
 
+(*
+   Print just the paths (like 'ls') or print a bunch of details (like 'ls -l')
+*)
+type format = Paths_only | Long [@@deriving show]
+
+val default_format : format
+
 (*
    Print the list of selected targets in alphabetical order, one per line.
 *)
 val run :
   target_roots:Scanning_root.t list ->
   targeting_conf:Find_targets.conf ->
+  format:format ->
   unit ->
   Exit_code.t
diff --git a/src/osemgrep/cli_scan/Scan_CLI.ml b/src/osemgrep/cli_scan/Scan_CLI.ml
index 69b54a89efdd..994d10099bc4 100644
--- a/src/osemgrep/cli_scan/Scan_CLI.ml
+++ b/src/osemgrep/cli_scan/Scan_CLI.ml
@@ -62,9 +62,10 @@ type conf = {
   show : Show_CLI.conf option;
   validate : Validate_CLI.conf option;
   test : Test_CLI.conf option;
-  ls : bool;
   experimental_requirements_lockfiles : bool;
   allow_dynamic_dependency_resolution : bool;
+  ls : bool;
+  ls_format : Ls_subcommand.format;
 }
 [@@deriving show]
 
@@ -109,9 +110,10 @@ let default : conf =
     show = None;
     validate = None;
     test = None;
-    ls = false;
     experimental_requirements_lockfiles = false;
     allow_dynamic_dependency_resolution = false;
+    ls = false;
+    ls_format = Ls_subcommand.default_format;
   }
 
 (*************************************************************************)
@@ -219,6 +221,11 @@ 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?
+*)
 let o_respect_gitignore : bool Term.t =
   H.negatable_flag [ "use-git-ignore" ] ~neg_options:[ "no-git-ignore" ]
     ~default:default.targeting_conf.respect_gitignore
@@ -232,6 +239,21 @@ filtering. Setting this flag does nothing if the scanning root is not
 in a git repository.
 |}
 
+let o_ignore_semgrepignore_files : bool Term.t =
+  let info =
+    Arg.info
+      [ "x-ignore-semgrepignore-files" ]
+      ~doc:
+        {|[INTERNAL] Ignore all '.semgrepignore' files found in the project tree for the
+purpose of selecting target files to be scanned by semgrep.
+Other filters may still apply.
+REQUIRES '--experimental'.
+THIS OPTION IS NOT PART OF THE SEMGREP API AND MAY
+CHANGE OR DISAPPEAR WITHOUT NOTICE.
+|}
+  in
+  Arg.value (Arg.flag info)
+
 let o_scan_unknown_extensions : bool Term.t =
   let default = default.targeting_conf.always_select_explicit_targets in
   H.negatable_flag
@@ -940,12 +962,24 @@ let o_ls : bool Term.t =
   let info =
     Arg.info [ "x-ls" ]
       ~doc:
-        {|[INTERNAL] List the selected target files and the skipped target
-files before any rule-specific or language-specific filtering. Then exit.
-The output format is unspecified.
+        {|[INTERNAL] List the selected target files
+before any rule-specific or language-specific filtering. Then exit.
+The default output format is one path per line.
+THIS OPTION IS NOT PART OF THE SEMGREP API AND MAY
+CHANGE OR DISAPPEAR WITHOUT NOTICE.
+|}
+  in
+  Arg.value (Arg.flag info)
+
+let o_ls_long : bool Term.t =
+  let info =
+    Arg.info [ "x-ls-long" ]
+      ~doc:
+        {|[INTERNAL] Show selected targets and skipped targets with reasons why
+they were skipped, using an unspecified output format.
+Implies --x-ls.
 THIS OPTION IS NOT PART OF THE SEMGREP API AND MAY
 CHANGE OR DISAPPEAR WITHOUT NOTICE.
-REQUIRES --experimental.
 |}
   in
   Arg.value (Arg.flag info)
@@ -1261,7 +1295,8 @@ let test_CLI_conf ~test ~target_roots ~config ~json ~optimizations
  *)
 let cmdline_term caps ~allow_empty_config : conf Term.t =
   (* !The parameters must be in alphabetic orders to match the order
-   * of the corresponding '$ o_xx $' further below! *)
+     of the corresponding '$ o_xx $' further below!
+  *)
   let combine allow_dynamic_dependency_resolution allow_untrusted_validators
       autofix baseline_commit common config dataflow_traces diff_depth dryrun
       dump_ast dump_command_for_core dump_engine_path emacs emacs_outputs error
@@ -1269,16 +1304,24 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
       experimental_requirements_lockfiles files_with_matches force_color
       gitlab_sast gitlab_sast_outputs gitlab_secrets gitlab_secrets_outputs
       _historical_secrets include_ incremental_output json json_outputs
-      junit_xml junit_xml_outputs lang ls matching_explanations
-      max_chars_per_line max_lines_per_finding max_log_list_entries
-      max_memory_mb max_target_bytes metrics num_jobs 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
+      junit_xml junit_xml_outputs lang matching_explanations max_chars_per_line
+      max_lines_per_finding max_log_list_entries max_memory_mb max_target_bytes
+      metrics num_jobs 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 =
+      _use_osemgrep_sarif 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
+      Logs.warn (fun m ->
+          m
+            "!!! You're using one or more options starting with '--x-'. These \
+             options are not part of the semgrep API. They will change or will \
+             be removed without notice !!! ");
     let target_roots, imply_always_select_explicit_targets =
       replace_target_roots_by_regular_files_where_needed caps
         ~experimental:(common.CLI_common.maturity =*= Maturity.Experimental)
@@ -1372,6 +1415,7 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
           scan_unknown_extensions || imply_always_select_explicit_targets;
         explicit_targets;
         respect_gitignore;
+        respect_semgrepignore_files = not x_ignore_semgrepignore_files;
         exclude_minified_files;
       }
     in
@@ -1431,7 +1475,12 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
             "The --trace-endpoint flag or SEMGREP_OTEL_ENDPOINT environment \
              variable is specified without --trace.\n\
              If you intend to enable tracing, please also add the --trace flag.");
-
+    let ls, ls_format =
+      (* --x-ls-long implies --x-ls *)
+      if x_ls_long then (true, Ls_subcommand.Long)
+      else if x_ls then (true, Ls_subcommand.default_format)
+      else (false, Ls_subcommand.default_format)
+    in
     {
       rules_source;
       target_roots;
@@ -1455,9 +1504,10 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
       test;
       trace;
       trace_endpoint;
-      ls;
       experimental_requirements_lockfiles;
       allow_dynamic_dependency_resolution;
+      ls;
+      ls_format;
     }
   in
   (* Term defines 'const' but also the '$' operator *)
@@ -1473,7 +1523,7 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
     $ o_files_with_matches $ o_force_color $ o_gitlab_sast
     $ o_gitlab_sast_outputs $ o_gitlab_secrets $ o_gitlab_secrets_outputs
     $ o_historical_secrets $ o_include $ o_incremental_output $ o_json
-    $ o_json_outputs $ o_junit_xml $ o_junit_xml_outputs $ o_lang $ o_ls
+    $ o_json_outputs $ o_junit_xml $ o_junit_xml_outputs $ o_lang
     $ o_matching_explanations $ o_max_chars_per_line $ o_max_lines_per_finding
     $ o_max_log_list_entries $ o_max_memory_mb $ o_max_target_bytes $ o_metrics
     $ o_num_jobs $ o_no_secrets_validation $ o_nosem $ o_optimizations $ o_oss
@@ -1485,7 +1535,7 @@ let cmdline_term caps ~allow_empty_config : conf Term.t =
     $ 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_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 22550ba28c04..ef60873bc63c 100644
--- a/src/osemgrep/cli_scan/Scan_CLI.mli
+++ b/src/osemgrep/cli_scan/Scan_CLI.mli
@@ -39,9 +39,14 @@ type conf = {
   show : Show_CLI.conf option;
   validate : Validate_CLI.conf option;
   test : Test_CLI.conf option;
-  ls : bool;
   experimental_requirements_lockfiles : bool;
   allow_dynamic_dependency_resolution : bool;
+  (* --x-* options are experimental forever! (= subject to change or removal
+     without notice) *)
+  (* --x-ls: *)
+  ls : bool;
+  (* --x-ls-long: *)
+  ls_format : Ls_subcommand.format;
 }
 [@@deriving show]
 
@@ -90,6 +95,7 @@ val o_gitlab_sast_outputs : string list Cmdliner.Term.t
 val o_gitlab_secrets : bool Cmdliner.Term.t
 val o_gitlab_secrets_outputs : string list Cmdliner.Term.t
 val o_historical_secrets : bool Cmdliner.Term.t
+val o_ignore_semgrepignore_files : bool Cmdliner.Term.t
 val o_incremental_output : bool Cmdliner.Term.t
 val o_json : bool Cmdliner.Term.t
 val o_json_outputs : string list Cmdliner.Term.t
diff --git a/src/osemgrep/cli_scan/Scan_subcommand.ml b/src/osemgrep/cli_scan/Scan_subcommand.ml
index b3d1e81a257a..2ae4a6f39b6e 100644
--- a/src/osemgrep/cli_scan/Scan_subcommand.ml
+++ b/src/osemgrep/cli_scan/Scan_subcommand.ml
@@ -164,19 +164,7 @@ let exit_code_of_errors ~strict (errors : Out.core_error list) : Exit_code.t =
 let core_errors_of_fatal_rule_errors (fatal_errors : Rule_error.t list) :
     Core_error.t list =
   fatal_errors
-  |> List_.map (fun (e : Rule_error.t) ->
-         let core_err = Core_error.error_of_rule_error e.file e in
-         (* We should definitely not drop rule errors here.
-            That being said, it shouldn't be possible to get no file here,
-            by construction.
-            The `file` field is set in `Rule.Error.t` from all of the entry points
-            into Parse_rule.ml
-         *)
-         if Fpath_.is_fake_file e.file then
-           Logs.err (fun m ->
-               m "no file found for rule error %s"
-                 (Core_error.string_of_error core_err));
-         core_err)
+  |> List_.map (fun (e : Rule_error.t) -> Core_error.error_of_rule_error e)
 
 (* we require stdout here to give the proper output, such as with --json *)
 let output_and_exit_from_fatal_core_errors_exn ~exit_code
@@ -594,31 +582,36 @@ let check_targets_with_rules
         choose_output_format_and_match_hook (caps :> < Cap.stdout >) conf rules
       in
       (* step 3': call the engine! *)
+      Logs.info (fun m ->
+          m "scan subcommand: %i valid rules, %i invalid rules, %i targets"
+            (List.length rules)
+            (List.length invalid_rules)
+            (List.length targets));
       Logs.info (fun m -> m "running the semgrep engine");
       let (result_or_exn : Core_result.result_or_exn) =
         match conf.targeting_conf.baseline_commit with
         | None ->
             Profiler.record profiler ~name:"core_time" (fun () ->
-                let core_run_for_osemgrep =
+                let { run } : Core_runner.func =
                   mk_core_run_for_osemgrep
                     (caps :> Core_scan.caps)
                     conf Differential_scan_config.WholeScan
                 in
-                core_run_for_osemgrep.run ?file_match_hook conf.core_runner_conf
-                  conf.targeting_conf (rules, invalid_rules) targets)
+                run ?file_match_hook conf.core_runner_conf conf.targeting_conf
+                  (rules, invalid_rules) targets)
         | Some baseline_commit ->
             (* scan_baseline calls internally Profiler.record "head_core_time"  *)
             (* diff scan mode *)
             let diff_scan_func : Diff_scan.diff_scan_func =
              fun ?(diff_config = Differential_scan_config.WholeScan) targets
                  rules ->
-              let core_run_for_osemgrep =
+              let { run } : Core_runner.func =
                 mk_core_run_for_osemgrep
                   (caps :> Core_scan.caps)
                   conf diff_config
               in
-              core_run_for_osemgrep.run ?file_match_hook conf.core_runner_conf
-                conf.targeting_conf (rules, invalid_rules) targets
+              run ?file_match_hook conf.core_runner_conf conf.targeting_conf
+                (rules, invalid_rules) targets
             in
             Diff_scan.scan_baseline
               (caps :> < Cap.chdir ; Cap.tmp >)
@@ -661,13 +654,15 @@ let check_targets_with_rules
           in
           Profiler.stop_ign profiler ~name:"total_time";
 
-          let rules_with_targets =
+          (* We'll report the number of valid rules, not the number of
+             rules applicable to our target files. *)
+          let valid_rules =
             match result_or_exn with
             | Ok r ->
-                r.rules_with_targets
+                r.valid_rules
                 |> List_.map (fun (rv : Rule.rule) ->
                        Rule_ID.to_string (fst rv.id))
-            | _ -> []
+            | Error _ -> []
           in
 
           if Metrics_.is_enabled () then (
@@ -699,7 +694,7 @@ let check_targets_with_rules
                 ());
           Logs.app (fun m ->
               m "Ran %s on %s: %s."
-                (String_.unit_str (List.length rules_with_targets) "rule")
+                (String_.unit_str (List.length valid_rules) "rule")
                 (String_.unit_str (List.length cli_output.paths.scanned) "file")
                 (String_.unit_str (List.length cli_output.results) "finding"));
 
@@ -897,7 +892,7 @@ let run_conf (caps : caps) (conf : Scan_CLI.conf) : Exit_code.t =
         (Common2.some conf.show)
   | _ when conf.ls ->
       Ls_subcommand.run ~target_roots:conf.target_roots
-        ~targeting_conf:conf.targeting_conf ()
+        ~targeting_conf:conf.targeting_conf ~format:conf.ls_format ()
   | _ ->
       (* --------------------------------------------------------- *)
       (* Let's go, this is an actual scan subcommand *)
diff --git a/src/osemgrep/core_runner/Core_runner.ml b/src/osemgrep/core_runner/Core_runner.ml
index 141379b10c4f..d0884cabf950 100644
--- a/src/osemgrep/core_runner/Core_runner.ml
+++ b/src/osemgrep/core_runner/Core_runner.ml
@@ -272,6 +272,7 @@ let targets_and_rules_of_lang_jobs (lang_jobs : Lang_job.t list) :
         (List_.append targets acc_targets, List_.append rules acc_rules))
       lang_jobs ([], [])
   in
+  (* TODO: deduplicate rules? *)
   (targets, rules)
 
 (* used only in Test_subcommand.ml *)
@@ -365,7 +366,15 @@ let mk_core_run_for_osemgrep (core_scan_func : Core_scan.func) : func =
        This mode doesn't tolerate scanning roots. This is checked in
        Core_scan.ml.
     *)
-    let rules, invalid_rules = rules_and_invalid in
+    let valid_rules, invalid_rules = rules_and_invalid in
+    (* Deduplicate rules for a correct rule count and avoid redundant
+       work or findings.
+       TODO: do this even earlier? *)
+    let valid_rules =
+      List_.deduplicate_gen
+        ~get_key:(fun r -> Rule_ID.to_string (fst r.Rule.id))
+        valid_rules
+    in
     let rule_errors : Core_error.t list =
       invalid_rules |> List_.map Core_error.error_of_invalid_rule
     in
@@ -380,22 +389,28 @@ let mk_core_run_for_osemgrep (core_scan_func : Core_scan.func) : func =
        in use, bypassing it without removing it seems complicated.
        See https://www.notion.so/r2cdev/Osemgrep-scanning-algorithm-5962232bfd74433ba50f97c86bd1a0f3
     *)
-    let lang_jobs = split_jobs_by_language targeting_conf rules targets in
+    let lang_jobs = split_jobs_by_language targeting_conf valid_rules targets in
     report_status_and_add_metrics_languages
-      ~respect_gitignore:targeting_conf.respect_gitignore lang_jobs rules
+      ~respect_gitignore:targeting_conf.respect_gitignore lang_jobs valid_rules
       targets;
-    let targets, rules = targets_and_rules_of_lang_jobs lang_jobs in
+    let targets, applicable_rules = targets_and_rules_of_lang_jobs lang_jobs in
+    Logs.debug (fun m ->
+        m "core runner: %i applicable rules of %i valid rules, %i invalid rules"
+          (List.length applicable_rules)
+          (List.length valid_rules)
+          (List.length invalid_rules));
     let config =
-      { config with target_source = Targets targets; rule_source = Rules rules }
+      {
+        config with
+        target_source = Targets targets;
+        rule_source = Rules applicable_rules;
+      }
     in
 
     (* !!!!Finally! this is where we branch to semgrep-core core scan fun!!! *)
     let/ res = core_scan_func config in
     let rules_with_targets =
-      lang_jobs
-      |> List.concat_map (fun { Lang_job.rules; _ } -> rules)
-      |> List_.deduplicate_gen ~get_key:(fun r ->
-             Rule_ID.to_string (fst r.Rule.id))
+      lang_jobs |> List.concat_map (fun { Lang_job.rules; _ } -> rules)
     in
     (* Reinject rule errors *)
     let res =
@@ -403,6 +418,7 @@ let mk_core_run_for_osemgrep (core_scan_func : Core_scan.func) : func =
         res with
         errors = rule_errors @ res.errors;
         skipped_rules = invalid_rules @ res.skipped_rules;
+        valid_rules;
         rules_with_targets;
       }
     in
diff --git a/src/osemgrep/language_server/Unit_LS.ml b/src/osemgrep/language_server/Unit_LS.ml
index 9adf9083cf3f..c3b2d0bdceac 100644
--- a/src/osemgrep/language_server/Unit_LS.ml
+++ b/src/osemgrep/language_server/Unit_LS.ml
@@ -84,7 +84,7 @@ let mock_run_results (files : string list) : Core_runner.result =
   let matches = List_.map match_of_file files in
   let (core : OutJ.core_output) =
     {
-      version = None;
+      version = Version.version;
       results = matches;
       errors = [];
       (* extra *)
diff --git a/src/osemgrep/language_server/server/User_settings.ml b/src/osemgrep/language_server/server/User_settings.ml
index 89e527b74523..36e5dcd6dd94 100644
--- a/src/osemgrep/language_server/server/User_settings.ml
+++ b/src/osemgrep/language_server/server/User_settings.ml
@@ -54,6 +54,7 @@ let find_targets_conf_of_t settings : Find_targets.conf =
     include_;
     max_target_bytes = settings.max_target_bytes;
     respect_gitignore = true;
+    respect_semgrepignore_files = true;
     baseline_commit = None;
     diff_depth = 0;
     always_select_explicit_targets = false;
diff --git a/src/osemgrep/networking/Rule_fetching.ml b/src/osemgrep/networking/Rule_fetching.ml
index f07cf2608483..c3187d082674 100644
--- a/src/osemgrep/networking/Rule_fetching.ml
+++ b/src/osemgrep/networking/Rule_fetching.ml
@@ -37,7 +37,7 @@ module XP = Xpattern
  * a pair, which would remove the need for partition_rules_and_invalid.
  *)
 type rules_and_origin = {
-  rules : Rule.rules;
+  rules : Rule.rule list;
   invalid_rules : Rule_error.invalid_rule list;
   origin : origin; (* used by Validate_subcommand *)
 }
diff --git a/src/osemgrep/networking/Rule_fetching.mli b/src/osemgrep/networking/Rule_fetching.mli
index 844a6c86e87e..17f9165f26c8 100644
--- a/src/osemgrep/networking/Rule_fetching.mli
+++ b/src/osemgrep/networking/Rule_fetching.mli
@@ -1,5 +1,5 @@
 type rules_and_origin = {
-  rules : Rule.rules;
+  rules : Rule.rule list;
   invalid_rules : Rule_error.invalid_rule list;
   origin : origin;
 }
diff --git a/src/osemgrep/reporting/Cli_json_output.ml b/src/osemgrep/reporting/Cli_json_output.ml
index 219bb3e162b7..a8da449f1e2d 100644
--- a/src/osemgrep/reporting/Cli_json_output.ml
+++ b/src/osemgrep/reporting/Cli_json_output.ml
@@ -42,9 +42,8 @@ let core_location_to_error_span (loc : Out.location) : Out.error_span =
   }
 
 (* Generate error message exposed to user *)
-let error_message ~rule_id ~(location : Out.location)
+let error_message ~rule_id ~(location : Out.location option)
     ~(error_type : Out.error_type) ~core_message : string =
-  let path = location.path in
   let rule_id_str_opt = Option.map Rule_ID.to_string rule_id in
   let error_context =
     match (rule_id_str_opt, error_type) with
@@ -56,10 +55,18 @@ let error_message ~rule_id ~(location : Out.location)
         | InvalidYaml | MatchingError | SemgrepMatchFound | TooManyMatches
         | FatalError | Timeout | OutOfMemory | TimeoutDuringInterfile
         | OutOfMemoryDuringInterfile ) ) ->
-        spf "when running %s on %s" id !!path
+        let suffix =
+          match location with
+          | None -> ""
+          | Some loc -> spf " on %s" !!(loc.path)
+        in
+        spf "when running %s%s" id suffix
     | Some id, IncompatibleRule _ -> id
     | Some id, MissingPlugin -> spf "for rule %s" id
-    | _ -> spf "at line %s:%d" !!path location.start.line
+    | _ -> (
+        match location with
+        | None -> ""
+        | Some loc -> spf "at line %s:%d" !!(loc.path) loc.start.line)
   in
   spf "%s %s:\n %s"
     (Error.string_of_error_type error_type)
@@ -203,12 +210,15 @@ let cli_error_of_core_error (x : Out.core_error) : Out.cli_error =
         | IncompatibleRule _
         | IncompatibleRule0
         | MissingPlugin ->
-            Some location.path
+            location |> Option.map (fun (x : Out.location) -> x.path)
       in
       let message =
         Some (error_message ~rule_id ~error_type ~location ~core_message)
       in
-      let spans = error_spans ~error_type ~location in
+      let spans =
+        let* location = location in
+        error_spans ~error_type ~location
+      in
       {
         (* LATER? seems to be either 2 (fatal) or 3 (invalid_code), so maybe
          * better to change the ATD spec and use a variant for cli_error.code
@@ -446,7 +456,7 @@ let cli_output_of_runner_result ~fixed_lines (core : Out.core_output)
       in
       let fixed_env = Fixed_lines.mk_env () in
       {
-        version;
+        version = Some version;
         (* Skipping the python intermediate RuleMatchMap for now *)
         results =
           matches
diff --git a/src/osemgrep/reporting/Skipped_report.ml b/src/osemgrep/reporting/Skipped_report.ml
index 8e0801d17ed1..12ba03312954 100644
--- a/src/osemgrep/reporting/Skipped_report.ml
+++ b/src/osemgrep/reporting/Skipped_report.ml
@@ -34,14 +34,16 @@ type skipped_targets_grouped = {
 let errors_to_skipped (errors : OutJ.core_error list) : OutJ.skipped_target list
     =
   errors
-  |> List_.map (fun OutJ.{ location; message; rule_id; _ } ->
-         OutJ.
-           {
-             path = location.path;
-             reason = Analysis_failed_parser_or_internal_error;
-             details = Some message;
-             rule_id;
-           })
+  |> List_.filter_map (fun OutJ.{ location; message; rule_id; _ } ->
+         let* loc = location in
+         Some
+           OutJ.
+             {
+               path = loc.path;
+               reason = Analysis_failed_parser_or_internal_error;
+               details = Some message;
+               rule_id;
+             })
 
 let group_skipped (skipped : OutJ.skipped_target list) : skipped_targets_grouped
     =
@@ -66,7 +68,8 @@ let group_skipped (skipped : OutJ.skipped_target list) : skipped_targets_grouped
         | Dotfile
         | Nonexistent_file
         | Irrelevant_rule
-        | Too_many_matches ->
+        | Too_many_matches
+        | Insufficient_permissions ->
             `Other)
       skipped
   in
diff --git a/src/parsing/Parse_contribution.ml b/src/parsing/Parse_contribution.ml
index 349d3f9e810b..aed675c7c5a2 100644
--- a/src/parsing/Parse_contribution.ml
+++ b/src/parsing/Parse_contribution.ml
@@ -1,13 +1,28 @@
+module Out = Semgrep_output_v1_j
+
 (** Collect information about the project contributions from git log. *)
 
+let contrib_to_contrib (contrib : Git_wrapper.contribution) : Out.contribution =
+  let Git_wrapper.
+        {
+          commit_hash;
+          commit_timestamp;
+          commit_author_name;
+          commit_author_email;
+        } =
+    contrib
+  in
+  Out.
+    {
+      commit_hash;
+      commit_timestamp = ATD_string_wrap.Datetime.wrap commit_timestamp;
+      contributor = { commit_author_name; commit_author_email };
+    }
+
 let get_contributions (caps : < Cap.exec >) :
     Semgrep_output_v1_j.contribution list =
-  (* ugly: this works because get_git_logs() uses a special format string
-   * to output the git log in a JSON format that matches
-   * the definition in semgrep_output_v1.atd contribution type.
-   * We use ~since:"last 30 days" because of our usage policy.
+  (* We use ~since:"last 30 days" because of our usage policy.
    * See https://semgrep.dev/docs/usage-limits
    *)
   let last_30_days = Common2.today () |> Common2.month_before in
-  Git_wrapper.logs caps ~since:last_30_days
-  |> List_.map Semgrep_output_v1_j.contribution_of_string
+  Git_wrapper.logs caps ~since:last_30_days |> List_.map contrib_to_contrib
diff --git a/src/parsing/Parse_rule.ml b/src/parsing/Parse_rule.ml
index 5ed272d7c54b..32ecd335479c 100644
--- a/src/parsing/Parse_rule.ml
+++ b/src/parsing/Parse_rule.ml
@@ -1061,7 +1061,7 @@ let parse_generic_ast ?(error_recovery = false) ?rewrite_rule_ids
       match ast with
       | [ { G.s = G.ExprStmt (e, _); _ } ] -> (
           let missing_rules_field () =
-            let loc = Tok.first_loc_of_file !!file in
+            let loc = Tok.first_loc_of_file file in
             yaml_error (Tok.tok_of_loc loc)
               "missing rules entry as top-level key"
           in
diff --git a/src/parsing/Parse_target.ml b/src/parsing/Parse_target.ml
index e11c3ea8f63f..ea2fe8c76b39 100644
--- a/src/parsing/Parse_target.ml
+++ b/src/parsing/Parse_target.ml
@@ -41,7 +41,7 @@ let errors_from_skipped_tokens xs =
   | [] -> Core_error.ErrorSet.empty
   | x :: _ ->
       let e = exn_of_loc x in
-      let err = E.exn_to_error None x.Tok.pos.file e in
+      let err = E.exn_to_error ~file:x.Tok.pos.file e in
       let locs =
         xs |> List_.map Semgrep_output_utils.location_of_token_location
       in
@@ -83,21 +83,26 @@ let just_parse_with_lang (lang : Lang.t) (file : Fpath.t) : Res.t =
 (* Entry point *)
 (*****************************************************************************)
 
-let just_resolve_name lang ast =
-  (* to be deterministic, reset the gensym; anyway right now semgrep is
-   * used only for local per-file analysis, so no need to have a unique ID
-   * among a set of files in a project like codegraph.
-   *)
-  AST_generic.SId.unsafe_reset_counter ();
-  Naming_AST.resolve lang ast;
+let run_analyses_after_name_resolution lang ast =
   Typing.check_program lang ast;
 
+  Implicit_return.mark_implicit_return lang ast;
+
   (* Flow-insensitive constant propagation. *)
   Constant_propagation.propagate_basic lang ast;
 
   (* Flow-sensitive constant propagation. *)
   Constant_propagation.propagate_dataflow lang ast
 
+let just_resolve_name lang ast =
+  (* to be deterministic, reset the gensym; anyway right now semgrep is
+   * used only for local per-file analysis, so no need to have a unique ID
+   * among a set of files in a project like codegraph.
+   *)
+  AST_generic.SId.unsafe_reset_counter ();
+  Naming_AST.resolve lang ast;
+  run_analyses_after_name_resolution lang ast
+
 let parse_and_resolve_name lang file =
   let res = just_parse_with_lang lang file in
   let ast = res.ast in
diff --git a/src/parsing/Parse_target.mli b/src/parsing/Parse_target.mli
index 476e8f2b5787..0cf227e265d4 100644
--- a/src/parsing/Parse_target.mli
+++ b/src/parsing/Parse_target.mli
@@ -10,6 +10,9 @@ val parse_and_resolve_name : Lang.t -> Fpath.t -> Parsing_result2.t
 (* no naming, just parsing *)
 val just_parse_with_lang : Lang.t -> Fpath.t -> Parsing_result2.t
 
+(* typing, const-prop, implicit-return, etc *)
+val run_analyses_after_name_resolution : Lang.t -> AST_generic.program -> unit
+
 (* no parsing, just naming *)
 val just_resolve_name : Lang.t -> AST_generic.program -> unit
 
diff --git a/src/reporting/Core_json_output.ml b/src/reporting/Core_json_output.ml
index 7de556dcbd79..74823eab450e 100644
--- a/src/reporting/Core_json_output.ml
+++ b/src/reporting/Core_json_output.ml
@@ -401,12 +401,12 @@ let match_to_match (x : Core_result.processed_match) :
      *)
   with
   | Tok.NoTokenLocation s ->
-      let loc = Tok.first_loc_of_file !!(x.pm.path.internal_path_to_content) in
+      let loc = Tok.first_loc_of_file x.pm.path.internal_path_to_content in
       let s =
         spf "NoTokenLocation with pattern %s, %s" x.pm.rule_id.pattern_string s
       in
       let err =
-        E.mk_error ~rule_id:x.pm.rule_id.id ~msg:s loc Out.MatchingError
+        E.mk_error ~rule_id:x.pm.rule_id.id ~msg:s ~loc Out.MatchingError
       in
       Error err
 [@@profiling]
@@ -416,15 +416,10 @@ let match_to_match (x : Core_result.processed_match) :
  *)
 let error_to_error (err : Core_error.t) : Out.core_error =
   let location =
-    match err.loc with
-    | Some loc ->
-        let file = loc.pos.file in
-        let startp, endp = OutUtils.position_range loc loc in
-        { Out.path = file; start = startp; end_ = endp }
-    (* TODO get rid of option in err.loc *)
-    | None ->
-        let pos = { Out.line = 1; col = 1; offset = 0 } in
-        { Out.path = Fpath_.fake_file; start = pos; end_ = pos }
+    let* loc = err.loc in
+    let file = loc.pos.file in
+    let startp, endp = OutUtils.position_range loc loc in
+    Some { Out.path = file; start = startp; end_ = endp }
   in
   let rule_id = err.rule_id in
   let error_type = err.typ in
@@ -584,7 +579,7 @@ let core_output_of_matches_and_errors (res : Core_result.t) : Out.core_output =
     interfile_languages_used =
       Some (List_.map (fun l -> Xlang.to_string l) res.interfile_languages_used);
     engine_requested = Some `OSS;
-    version = Some Version.version;
+    version = Version.version;
   }
 [@@profiling]
 
diff --git a/src/tainting/Dataflow_tainting.ml b/src/tainting/Dataflow_tainting.ml
index 8ec376e7c7c0..dbd6dffd523e 100644
--- a/src/tainting/Dataflow_tainting.ml
+++ b/src/tainting/Dataflow_tainting.ml
@@ -144,6 +144,18 @@ let propagate_through_indexes env =
 (* Helpers *)
 (*****************************************************************************)
 
+let log_timeout_warning config opt_name timeout =
+  match timeout with
+  | `Ok -> ()
+  | `Timeout ->
+      (* nosemgrep: no-logs-in-library *)
+      Logs.warn (fun m ->
+          m
+            "Fixpoint timeout while performing taint analysis [rule: %s file: \
+             %s func: %s]"
+            (Rule_ID.to_string config.rule_id)
+            config.filepath (opt_name ||| "???"))
+
 let map_check_expr env check_expr xs =
   let rev_taints_and_shapes, lval_env =
     xs
@@ -590,7 +602,7 @@ let effects_of_call_func_arg fun_exp fun_shape args_taints =
   | S.Arg fun_arg ->
       [ Effect.ToSinkInCall { callee = fun_exp; arg = fun_arg; args_taints } ]
   | __else__ ->
-      Log.warn (fun m ->
+      Log.debug (fun m ->
           m "Function (?) %s has shape %s"
             (Display_IL.string_of_exp fun_exp)
             (S.show_shape fun_shape));
@@ -1711,10 +1723,11 @@ let input_env ~enter_env ~(flow : F.cfg) mapping ni =
       | [ penv ] -> penv
       | penv1 :: penvs -> List.fold_left Lval_env.union penv1 penvs)
 
-let transfer : env -> flow:F.cfg -> Lval_env.t D.transfn =
- fun enter_env ~flow
+let rec transfer : env -> fun_cfg:F.fun_cfg -> Lval_env.t D.transfn =
+ fun enter_env ~fun_cfg
      (* the transfer function to update the mapping at node index ni *)
        mapping ni ->
+  let flow = fun_cfg.cfg in
   (* DataflowX.display_mapping flow mapping show_tainted; *)
   let in' : Lval_env.t =
     input_env ~enter_env:enter_env.lval_env ~flow mapping ni
@@ -1780,38 +1793,20 @@ let transfer : env -> flow:F.cfg -> Lval_env.t D.transfn =
         let effects = effects_of_tainted_return env taints shape tok in
         record_effects env effects;
         lval_env'
-    | NLambda params ->
-        params
-        |> List.fold_left
-             (fun lval_env param ->
-               match param with
-               | Param { pname = var; _ } ->
-                   (* This is a *new* variable, so we clean any taint that we may have
-                    * attached to it previously. This can happen when a lambda is called
-                    * inside a loop. *)
-                   let lval_env =
-                     Lval_env.clean lval_env (LV.lval_of_var var)
-                   in
-                   (* Now check if the parameter is itself a taint source. *)
-                   let _taints, _shape, lval_env =
-                     check_tainted_var { env with lval_env } var
-                   in
-                   lval_env
-               | PatternParam _ (* TODO *)
-               | FixmeParam ->
-                   lval_env)
-             in'
     | NGoto _
     | Enter
     | Exit
     | TrueNode _
     | FalseNode _
     | Join
-    | OtherJoin
     | NOther _
     | NTodo _ ->
         in'
   in
+  let effects_lambdas, out' =
+    do_lambdas { env with lval_env = out' } fun_cfg.lambdas node
+  in
+  env.effects_acc := Effects.union effects_lambdas !(env.effects_acc);
   let env_at_exit = { env with lval_env = out' } in
   check_tainted_control_at_exit node env_at_exit;
   check_tainted_at_exit_sinks node env_at_exit;
@@ -1822,53 +1817,136 @@ let transfer : env -> flow:F.cfg -> Lval_env.t D.transfn =
         (Lval_env.to_string in') (Lval_env.to_string out'));
   { D.in_env = in'; out_env = out' }
 
-(*****************************************************************************)
-(* Entry point *)
-(*****************************************************************************)
+(* In OSS, lambdas are mostly treated like statement blocks, that is, we
+ * check the body of the lambda at the place where it is called, but we
+ * do not "connect" actual arguments with formals, nor we track if the
+ * lambda returns any taint.
+ *
+ * TODO: In Pro we should do inter-procedural analysis here. *)
+and do_lambdas env (lambdas : IL.lambdas_cfgs) node =
+  let node_is_call =
+    (* See 'out_env' below. *)
+    match node.F.n with
+    | NInstr i -> (
+        match i.i with
+        | Call _
+        | CallSpecial _
+        | New _ ->
+            true
+        | Assign _
+        | AssignAnon _
+        | FixmeInstr _ ->
+            false)
+    | __else__ -> false
+  in
+  (* We visit lambdas at their "use" site (where they are fetched), so we can e.g.
+   * propagate taint from an object receiving a method call, to a lambda being
+   * passed to that method. *)
+  let lambdas_in_node =
+    IL_helpers.rlvals_of_node node.F.n
+    |> List_.filter_map (LV.lval_is_lambda lambdas)
+  in
+  let num_lambdas = List.length lambdas_in_node in
+  if num_lambdas > 0 then
+    Log.debug (fun m ->
+        m "There are %d lambda(s) occurring in: %s" num_lambdas
+          (Display_IL.short_string_of_node_kind node.F.n));
+  let effects_lambdas, out_envs_lambdas =
+    lambdas_in_node
+    |> List_.map (fun (name, lcfg) ->
+           let in_env =
+             (* We do some processing of the lambda parameters but it's mainly
+              * to enable taint propagation, e.g.
+              *
+              *     obj.do_something(lambda x: sink(x))
+              *
+              * so we can propagate taint from `obj` to `x`.
+              *)
+             lcfg.params
+             |> List.fold_left
+                  (fun lval_env param ->
+                    match param with
+                    | Param { pname = var; _ } ->
+                        (* This is a *new* variable, so we clean any taint that we may have
+                         * attached to it previously. This can happen when a lambda is called
+                         * inside a loop. *)
+                        let lval_env =
+                          Lval_env.clean lval_env (LV.lval_of_var var)
+                        in
+                        (* Now check if the parameter is itself a taint source. *)
+                        let _taints, _shape, lval_env =
+                          check_tainted_var { env with lval_env } var
+                        in
+                        lval_env
+                    | PatternParam _ (* TODO *)
+                    | FixmeParam ->
+                        lval_env)
+                  env.lval_env
+           in
+           fixpoint_lambda env.lang env.options env.config env.best_matches
+             env.java_props (IL.str_of_name name) lcfg in_env)
+    |> List_.split
+  in
+  let effects = Effects.union_list effects_lambdas in
+  let out_env =
+    if node_is_call then
+      (* We only take the side-effects of the lambda into consideration if the
+       * node is a call, so the lambda is either the callee or one of its arguments.
+       * E.g.
+       *
+       *     do_something([]() { taint(p) });
+       *     sink(p) // finding wanted
+       *
+       * We assume that these lambdas are beign evaluated and that their side-effects
+       * should affect the subsequent statements.
+       *)
+      Lval_env.union_list ~default:env.lval_env out_envs_lambdas
+    else
+      (* If lambdas are not part of a call, we don't make their side-effects visible.
+       * E.g.
+       *
+       *     void test(int *p) {
+       *       auto f1 = [&p]() {
+       *         source(p);
+       *       };
+       *       auto f2 = [&p]() {
+       *         sink(p); // NO finding wanted
+       *       };
+       *     }
+       *)
+      env.lval_env
+  in
+  (effects, out_env)
 
-let (fixpoint :
-      ?in_env:Lval_env.t ->
-      ?name:Var_env.var ->
-      Lang.t ->
-      Rule_options.t ->
-      config ->
-      java_props_cache ->
-      F.cfg ->
-      Effects.t * mapping) =
- fun ?in_env ?name:opt_name lang options config java_props flow ->
+and fixpoint_lambda lang options config best_matches java_props_cache name
+    lambda_cfg in_env : Effects.t * Lval_env.t =
+  Log.debug (fun m ->
+      m "Analyzing lambda %s (%s)" name (Lval_env.to_string in_env));
+  let effects, mapping =
+    fixpoint_aux ~in_env ~name lang options config best_matches java_props_cache
+      lambda_cfg
+  in
+  let effects =
+    effects
+    |> Effects.filter (function
+         | ToSink _
+         | ToLval _
+         | ToSinkInCall _ ->
+             true
+         | ToReturn _ -> false)
+  in
+  let out_env = mapping.(lambda_cfg.cfg.exit).Dataflow_core.out_env in
+  (effects, out_env)
+
+and fixpoint_aux ?in_env ?name:opt_name lang options config best_matches
+    java_props fun_cfg =
+  let flow = fun_cfg.cfg in
   let init_mapping = DataflowX.new_node_array flow Lval_env.empty_inout in
   let enter_lval_env =
     match in_env with
     | None -> Lval_env.empty
     | Some in_env -> in_env
   in
-  let best_matches =
-    (* Here we compute the "canonical" or "best" source/sanitizer/sink matches,
-     * for each source/sanitizer/sink we check whether there is a "best match"
-     * among all the potential matches in the CFG.
-     * See NOTE "Best matches" *)
-    TM.best_matches_in_nodes
-      ~sub_matches_of_orig:(fun orig ->
-        let sources =
-          orig_is_source config orig |> List.to_seq
-          |> Seq.filter (fun (m : R.taint_source TM.t) -> m.spec.source_exact)
-          |> Seq.map (fun m -> TM.Any m)
-        in
-        let sanitizers =
-          orig_is_sanitizer config orig
-          |> List.to_seq
-          |> Seq.filter (fun (m : R.taint_sanitizer TM.t) ->
-                 m.spec.sanitizer_exact)
-          |> Seq.map (fun m -> TM.Any m)
-        in
-        let sinks =
-          orig_is_sink config orig |> List.to_seq
-          |> Seq.filter (fun (m : R.taint_sink TM.t) -> m.spec.sink_exact)
-          |> Seq.map (fun m -> TM.Any m)
-        in
-        sources |> Seq.append sanitizers |> Seq.append sinks)
-      flow
-  in
   let env =
     {
       lang;
@@ -1883,12 +1961,57 @@ let (fixpoint :
   in
   (* THINK: Why I cannot just update mapping here ? if I do, the mapping gets overwritten later on! *)
   (* DataflowX.display_mapping flow init_mapping show_tainted; *)
-  let end_mapping =
+  let end_mapping, timeout =
     DataflowX.fixpoint ~timeout:Limits_semgrep.taint_FIXPOINT_TIMEOUT
-      ~eq_env:Lval_env.equal ~init:init_mapping ~trans:(transfer env ~flow)
+      ~eq_env:Lval_env.equal ~init:init_mapping ~trans:(transfer env ~fun_cfg)
       ~forward:true ~flow
   in
+  log_timeout_warning config opt_name timeout;
   let exit_lval_env = end_mapping.(flow.exit).D.out_env in
   effects_from_arg_updates_at_exit enter_lval_env exit_lval_env
   |> record_effects env;
   (!(env.effects_acc), end_mapping)
+
+(*****************************************************************************)
+(* Entry point *)
+(*****************************************************************************)
+
+and (fixpoint :
+      ?in_env:Lval_env.t ->
+      ?name:Var_env.var ->
+      Lang.t ->
+      Rule_options.t ->
+      config ->
+      java_props_cache ->
+      F.fun_cfg ->
+      Effects.t * mapping) =
+ fun ?in_env ?name:opt_name lang options config java_props fun_cfg ->
+  let best_matches =
+    (* Here we compute the "canonical" or "best" source/sanitizer/sink matches,
+     * for each source/sanitizer/sink we check whether there is a "best match"
+     * among all the potential matches in the CFG.
+     * See NOTE "Best matches" *)
+    fun_cfg
+    |> TM.best_matches_in_nodes ~sub_matches_of_orig:(fun orig ->
+           let sources =
+             orig_is_source config orig |> List.to_seq
+             |> Seq.filter (fun (m : R.taint_source TM.t) ->
+                    m.spec.source_exact)
+             |> Seq.map (fun m -> TM.Any m)
+           in
+           let sanitizers =
+             orig_is_sanitizer config orig
+             |> List.to_seq
+             |> Seq.filter (fun (m : R.taint_sanitizer TM.t) ->
+                    m.spec.sanitizer_exact)
+             |> Seq.map (fun m -> TM.Any m)
+           in
+           let sinks =
+             orig_is_sink config orig |> List.to_seq
+             |> Seq.filter (fun (m : R.taint_sink TM.t) -> m.spec.sink_exact)
+             |> Seq.map (fun m -> TM.Any m)
+           in
+           sources |> Seq.append sanitizers |> Seq.append sinks)
+  in
+  fixpoint_aux ?in_env ?name:opt_name lang options config best_matches
+    java_props fun_cfg
diff --git a/src/tainting/Dataflow_tainting.mli b/src/tainting/Dataflow_tainting.mli
index d640eb42cde5..7ab3cba4f6e0 100644
--- a/src/tainting/Dataflow_tainting.mli
+++ b/src/tainting/Dataflow_tainting.mli
@@ -104,7 +104,7 @@ val fixpoint :
   Rule_options.t ->
   config ->
   java_props_cache ->
-  IL.cfg ->
+  IL.fun_cfg ->
   Shape_and_sig.Effects.t * mapping
 (** Main entry point, [fixpoint config cfg] returns a mapping (effectively a set)
   * containing all the tainted variables in [cfg]. Besides, if it infers any taint
diff --git a/src/tainting/Shape_and_sig.ml b/src/tainting/Shape_and_sig.ml
index b8fd05091198..d27e58f7fb48 100644
--- a/src/tainting/Shape_and_sig.ml
+++ b/src/tainting/Shape_and_sig.ml
@@ -348,6 +348,7 @@ and Effect : sig
   val show : t -> string
 
   (* Mainly for debugging *)
+  val show_sink : sink -> string
   val show_args_taints : args_taints -> string
   val show_taints_to_sink : taints_to_sink -> string
   val show_taints_to_return : taints_to_return -> string
@@ -547,6 +548,7 @@ and Effects : sig
 
   val show : t -> string
   val add_list : Effect.t list -> t -> t
+  val union_list : t list -> t
 end = struct
   include Set.Make (struct
     type t = Effect.t
@@ -558,6 +560,7 @@ end = struct
     s |> to_seq |> List.of_seq |> List_.map Effect.show |> String.concat "; "
 
   let add_list elts t = List.fold_left (fun set e -> add e set) t elts
+  let union_list ts = List.fold_left union empty ts
 end
 
 (** A (polymorphic) taint signature: simply a set of results for a function.
diff --git a/src/tainting/Taint_lval_env.ml b/src/tainting/Taint_lval_env.ml
index fd9d982fbfe5..9d7663af4091 100644
--- a/src/tainting/Taint_lval_env.ml
+++ b/src/tainting/Taint_lval_env.ml
@@ -24,7 +24,7 @@ module Taints = T.Taint_set
 module H = IL_helpers
 module Var_env = Dataflow_var_env
 module VarMap = Var_env.VarMap
-module NameMap = Map.Make (H.NameOrdered)
+module NameMap = IL.NameMap
 open Shape_and_sig.Shape
 module Shape = Taint_shape
 
@@ -103,6 +103,8 @@ let union le1 le2 =
       VarMap.empty;
   }
 
+let union_list ?(default = empty) les = List.fold_left union default les
+
 (* HACK: Because we don't have a "Class" type, classes have themselves as types. *)
 let is_class_name (name : IL.name) =
   match (!(name.id_info.id_resolved), !(name.id_info.id_type)) with
diff --git a/src/tainting/Taint_lval_env.mli b/src/tainting/Taint_lval_env.mli
index f2dc171237e8..64212134c971 100644
--- a/src/tainting/Taint_lval_env.mli
+++ b/src/tainting/Taint_lval_env.mli
@@ -98,6 +98,7 @@ val union : env -> env -> env
      branch, then x.a_1. ... . a_N may no longer be clean, but we assume the
      best case scenario to reduce FPs. *)
 
+val union_list : ?default:env -> env list -> env
 val equal : env -> env -> bool
 
 val equal_by_lval : env -> env -> IL.lval -> bool
diff --git a/src/tainting/Taint_shape.ml b/src/tainting/Taint_shape.ml
index 2a557eb66fd5..49248c5ddf4a 100644
--- a/src/tainting/Taint_shape.ml
+++ b/src/tainting/Taint_shape.ml
@@ -213,7 +213,7 @@ and find_in_shape offset shape =
   | Obj obj -> find_in_obj offset obj
   | Arg _ ->
       (* TODO: Here we should "refine" the arg shape, it should be an Obj shape. *)
-      Log.warn (fun m ->
+      Log.debug (fun m ->
           m "Could not find offset %s in polymorphic shape %s"
             (debug_offset offset) (show_shape shape));
       None
diff --git a/src/tainting/Taint_spec_match.ml b/src/tainting/Taint_spec_match.ml
index 9a7d4c225ae9..8ba1a06865f9 100644
--- a/src/tainting/Taint_spec_match.ml
+++ b/src/tainting/Taint_spec_match.ml
@@ -139,7 +139,7 @@ end
 let is_best_match = Best_matches.is_best_match
 
 (* See NOTE "Best matches" for more context. *)
-let best_matches_in_nodes ~sub_matches_of_orig flow =
+let best_matches_in_nodes ~sub_matches_of_orig fun_cfg =
   let find_origs_visitor =
     object (_self : 'self)
       inherit [_] IL.iter as super
@@ -183,9 +183,8 @@ let best_matches_in_nodes ~sub_matches_of_orig flow =
    * thus has a range that contains them all, is the chosen as the "best"
    * match of the sink spec.
    * *)
-  flow.CFG.reachable |> CFG.NodeiSet.to_seq
-  |> Seq.concat_map (fun ni ->
-         let node = flow.CFG.graph#nodes#assoc ni in
+  fun_cfg |> IL_helpers.reachable_nodes
+  |> Seq.concat_map (fun node ->
          let all_origs : IL.orig Seq.t =
            let origs = ref Seq.empty in
            find_origs_visitor#visit_node origs node;
diff --git a/src/tainting/Taint_spec_match.mli b/src/tainting/Taint_spec_match.mli
index f7a79230f101..da4ff2a88afa 100644
--- a/src/tainting/Taint_spec_match.mli
+++ b/src/tainting/Taint_spec_match.mli
@@ -54,7 +54,5 @@ val is_best_match : Best_matches.t -> 'spec t -> bool
 (** Similar to 'is_exact' but based on "top matches". *)
 
 val best_matches_in_nodes :
-  sub_matches_of_orig:(IL.orig -> any Seq.t) ->
-  (IL.node, _) CFG.t ->
-  Best_matches.t
+  sub_matches_of_orig:(IL.orig -> any Seq.t) -> IL.fun_cfg -> Best_matches.t
 (** Collect the top-level matches in a CFG. *)
diff --git a/src/targeting/Find_targets.ml b/src/targeting/Find_targets.ml
index 1aa8bee38302..c9a197ced1d9 100644
--- a/src/targeting/Find_targets.ml
+++ b/src/targeting/Find_targets.ml
@@ -34,7 +34,7 @@ module Log = Log_targeting.Log
      files for a given language etc. If file system changes
      (i.e. git checkout), create a new TargetManager object
 
-     If respect_git_ignore is true then will only consider files that are
+     If respect_gitignore is true then will only consider files that are
      tracked or (untracked but not ignored) by git
 
      If git_baseline_commit is true then will only consider files that have
@@ -128,10 +128,8 @@ type conf = {
   *)
   include_ : string list option;
   max_target_bytes : int;
-  (* Whether or not follow what is specified in the .gitignore
-   * The .semgrepignore are always respected.
-   *)
   respect_gitignore : bool;
+  respect_semgrepignore_files : bool;
   always_select_explicit_targets : bool;
   explicit_targets : Explicit_targets.t;
   (* osemgrep-only: option
@@ -159,6 +157,7 @@ let default_conf : conf =
     *)
     max_target_bytes = 1000000;
     respect_gitignore = true;
+    respect_semgrepignore_files = true;
     always_select_explicit_targets = false;
     explicit_targets = Explicit_targets.empty;
     exclude_minified_files = false;
@@ -272,7 +271,14 @@ let filter_paths
   target_files
   |> List.iter (fun fppath ->
          match filter_path ign include_filter fppath with
-         | Keep -> add fppath
+         | Keep -> (
+             (* This section is similar to what we have in
+                'walk_skip_and_collect' but the rest is sufficiently different
+                that sharing code makes things complicated
+                (e.g. no dir access filtering for git targets) *)
+             match Skip_target.filter_file_access_permissions fppath.fpath with
+             | Ok _path -> add fppath
+             | Error skipped -> skip skipped)
          (* shouldn't happen if we work on the output of 'git ls-files *)
          | Dir -> ()
          | Skip x -> skip x
@@ -335,28 +341,34 @@ let walk_skip_and_collect (ign : Gitignore.filter)
 
   (* mostly a copy-paste of List_files.list_regular_files() *)
   let rec aux (dir : Fppath.t) =
-    Log.debug (fun m ->
-        m "listing dir %s (ppath = %s)" !!(dir.fpath)
-          (Ppath.to_string_for_tests dir.ppath));
-    (* TODO? should we sort them first? *)
-    let entries = List_files.read_dir_entries dir.fpath in
-    (* TODO: factorize code with filter_paths? *)
-    entries
-    |> List.iter (fun name ->
-           let fpath =
-             (* if scan_root was "." we want to display paths as "foo/bar"
-              * and not "./foo/bar"
-              *)
-             if Fpath.equal dir.fpath (Fpath.v ".") then Fpath.v name
-             else Fpath.add_seg dir.fpath name
-           in
-           let ppath = Ppath.add_seg dir.ppath name in
-           let fppath : Fppath.t = { fpath; ppath } in
-           match filter_path ign include_filter fppath with
-           | Keep -> add fppath
-           | Skip skipped -> skip skipped
-           | Dir -> aux fppath
-           | Ignore_silently -> ())
+    match Skip_target.filter_dir_access_permissions dir.fpath with
+    | Error skipped -> skip skipped
+    | Ok _path ->
+        Log.debug (fun m ->
+            m "listing dir %s (ppath = %s)" !!(dir.fpath)
+              (Ppath.to_string_for_tests dir.ppath));
+        (* TODO? should we sort them first? *)
+        let entries = List_files.read_dir_entries dir.fpath in
+        (* TODO: factorize code with filter_paths? *)
+        entries
+        |> List.iter (fun name ->
+               let fpath =
+                 (* if scan_root was "." we want to display paths as "foo/bar"
+                  * and not "./foo/bar"
+                  *)
+                 if Fpath.equal dir.fpath (Fpath.v ".") then Fpath.v name
+                 else Fpath.add_seg dir.fpath name
+               in
+               let ppath = Ppath.add_seg dir.ppath name in
+               let fppath : Fppath.t = { fpath; ppath } in
+               match filter_path ign include_filter fppath with
+               | Keep -> (
+                   match Skip_target.filter_file_access_permissions fpath with
+                   | Ok _path -> add fppath
+                   | Error skipped -> skip skipped)
+               | Skip skipped -> skip skipped
+               | Dir -> aux fppath
+               | Ignore_silently -> ())
   in
   aux scan_root;
   (* Let's not worry about file order here until we have to.
@@ -594,17 +606,22 @@ let setup_path_filters conf (project_roots : Project.roots) :
     project_roots
   in
   (* filter with .gitignore and .semgrepignore *)
-  let exclusion_mechanism =
+  let exclusion_mechanism : Semgrepignore.exclusion_mechanism =
     match kind with
     | Git_project
     | Gitignore_project ->
-        if conf.respect_gitignore then Semgrepignore.Gitignore_and_semgrepignore
-        else Semgrepignore.Only_semgrepignore
+        {
+          use_gitignore_files = conf.respect_gitignore;
+          use_semgrepignore_files = conf.respect_semgrepignore_files;
+        }
     | Mercurial_project
     | Subversion_project
     | Darcs_project
     | Other_project ->
-        Semgrepignore.Only_semgrepignore
+        {
+          use_gitignore_files = false;
+          use_semgrepignore_files = conf.respect_semgrepignore_files;
+        }
   in
   (* filter also the --include and --exclude from the CLI args
    * (the paths: exclude: include: in a rule are handled elsewhere, in
@@ -698,6 +715,7 @@ let get_targets_for_project conf (project_roots : Project.roots) =
   let git_untracked = git_list_untracked_files project_roots in
   let selected_targets, skipped_targets =
     match (git_tracked, git_untracked) with
+    (* Git only *)
     | Some tracked, Some untracked ->
         Log.debug (fun m ->
             m "target file candidates from git: tracked: %i, untracked: %i"
@@ -705,6 +723,7 @@ let get_targets_for_project conf (project_roots : Project.roots) =
               (Fppath_set.cardinal untracked));
         let all_files = Fppath_set.union tracked untracked in
         all_files |> Fppath_set.elements |> filter_targets conf project_roots
+    (* Non-Git projects *)
     | None, _
     | _, None ->
         get_targets_from_filesystem conf project_roots
diff --git a/src/targeting/Find_targets.mli b/src/targeting/Find_targets.mli
index 76eacc6bf796..96f320bb4ad6 100644
--- a/src/targeting/Find_targets.mli
+++ b/src/targeting/Find_targets.mli
@@ -35,10 +35,14 @@ type conf = {
    *)
   include_ : string list option;
   max_target_bytes : int;
-  (* whether or not follow what is specified in the .gitignore
-   * TODO? what about .semgrepignore?
-   *)
+  (* Whether to respect what is specified in the '.gitignore' files
+     found in the project and extended by optional '.semgrepignore' files.
+     Note that Git supports other sources of gitignore patterns, making
+     this option confusing. *)
   respect_gitignore : bool;
+  (* Whether to respect or ignore the '.semgrepignore' files found
+     in the project. *)
+  respect_semgrepignore_files : bool;
   (* Language-specific filtering: CLI option '--scan-unknown-extensions'
      allows explicit targets (files on the command line) to bypass
      normal language detection.
diff --git a/src/targeting/Find_targets_old.ml b/src/targeting/Find_targets_old.ml
index 1a1abd456514..34ba7bb28e50 100644
--- a/src/targeting/Find_targets_old.ml
+++ b/src/targeting/Find_targets_old.ml
@@ -52,16 +52,17 @@ let sort_files_by_decreasing_size files =
    the language-independent 'select_global_targets'.
 *)
 let global_filter ~opt_lang ~sort_by_decr_size paths =
-  let paths, skipped1 =
+  let paths, skipped1 = Skip_target.exclude_inaccessible_files paths in
+  let paths, skipped2 =
     match opt_lang with
     | None -> (paths, [])
     | Some lang -> Guess_lang.inspect_files lang paths
   in
-  let paths, skipped2 =
+  let paths, skipped3 =
     Skip_target.exclude_big_files !Flag_semgrep.max_target_bytes paths
   in
-  let paths, skipped3 = Skip_target.exclude_minified_files paths in
-  let skipped = List_.flatten [ skipped1; skipped2; skipped3 ] in
+  let paths, skipped4 = Skip_target.exclude_minified_files paths in
+  let skipped = List_.flatten [ skipped1; skipped2; skipped3; skipped4 ] in
   let sorted_paths =
     if sort_by_decr_size then sort_files_by_decreasing_size paths else paths
   in
@@ -255,12 +256,12 @@ let get_targets conf (scanning_roots : Scanning_root.t list) =
            match (proj_kind : Project.kind) with
            | Git_project
            | Gitignore_project ->
-               Gitignore_and_semgrepignore
+               { use_gitignore_files = true; use_semgrepignore_files = true }
            | Mercurial_project
            | Subversion_project
            | Darcs_project
            | Other_project ->
-               Only_semgrepignore
+               { use_gitignore_files = false; use_semgrepignore_files = true }
          in
          let ign =
            Semgrepignore.create ~cli_patterns:conf.exclude
diff --git a/src/targeting/Semgrepignore.ml b/src/targeting/Semgrepignore.ml
index 4b35bb4ad88b..d924a89e1383 100644
--- a/src/targeting/Semgrepignore.ml
+++ b/src/targeting/Semgrepignore.ml
@@ -35,7 +35,6 @@
      (we need to answer this to figure out the consequences of the migration
      plan)
 *)
-
 type builtin_semgrepignore = Empty | Semgrep_scan_legacy
 
 (*
@@ -44,8 +43,10 @@ type builtin_semgrepignore = Empty | Semgrep_scan_legacy
 
    Honor them with a deprecation warning.
 *)
-
-type exclusion_mechanism = Gitignore_and_semgrepignore | Only_semgrepignore
+type exclusion_mechanism = {
+  use_gitignore_files : bool;
+  use_semgrepignore_files : bool;
+}
 
 (*
    The legacy built-in semgrepignore.
@@ -87,7 +88,7 @@ testsuite/
 |}
 
 let gitignore_files = ("gitignore", ".gitignore")
-let semgrep_ignore_files = ("semgrepignore", ".semgrepignore")
+let semgrepignore_files = ("semgrepignore", ".semgrepignore")
 
 let contents_of_builtin_semgrepignore = function
   | Empty -> ""
@@ -121,25 +122,45 @@ let create ?(cli_patterns = []) ~builtin_semgrepignore ~exclusion_mechanism
       patterns = cli_patterns;
     }
   in
-  let gitignore_filenames =
-    match exclusion_mechanism with
-    | Gitignore_and_semgrepignore -> [ gitignore_files; semgrep_ignore_files ]
-    | Only_semgrepignore -> [ semgrep_ignore_files ]
+  let kinds_of_ignore_files_to_consult =
+    (* order matters: first gitignore then semgrepignore *)
+    (if exclusion_mechanism.use_gitignore_files then [ gitignore_files ] else [])
+    @
+    if exclusion_mechanism.use_semgrepignore_files then [ semgrepignore_files ]
+    else []
   in
-  (* Check if there is a top level .semgrepignore. If not use builtins *)
-  let semgrep_ignore_exists =
+  (*
+     Check if there is a top-level '.semgrepignore'. If not, use builtins.
+
+     We don't check for '.semgrepignore' down the tree, so if a user needs
+     to override the default semgrepignore rules, they need at least an
+     empty root '.semgrepignore' file.
+  *)
+  let root_semgrepignore_exists =
     let gitignore_cache =
-      Gitignores_cache.create ~gitignore_filenames:[ semgrep_ignore_files ]
+      Gitignore_cache.create ~gitignore_filenames:[ semgrepignore_files ]
         ~project_root ()
     in
-    Gitignores_cache.load gitignore_cache Ppath.root |> Option.is_some
+    Gitignore_cache.load gitignore_cache Ppath.root |> Option.is_some
   in
+
+  (*
+     This condition determines whether the built-in semgrepignore rules
+     should apply.
+  *)
+  let root_semgrepignore_exists_and_is_consulted =
+    root_semgrepignore_exists && exclusion_mechanism.use_semgrepignore_files
+  in
+
   let higher_priority_levels =
-    if semgrep_ignore_exists then [ cli_level ]
-    else [ builtin_level; cli_level ]
+    if root_semgrepignore_exists_and_is_consulted then [ cli_level ]
+    else
+      (* use the built-in semgrepignore rules in the absence of a root
+         '.semgrepignore' file *)
+      [ builtin_level; cli_level ]
   in
   let gitignore_filter =
-    Gitignore_filter.create ~higher_priority_levels ~gitignore_filenames
-      ~project_root ()
+    Gitignore_filter.create ~higher_priority_levels
+      ~gitignore_filenames:kinds_of_ignore_files_to_consult ~project_root ()
   in
   gitignore_filter
diff --git a/src/targeting/Semgrepignore.mli b/src/targeting/Semgrepignore.mli
index 28d3aceae738..a4b7aa8827f9 100644
--- a/src/targeting/Semgrepignore.mli
+++ b/src/targeting/Semgrepignore.mli
@@ -18,7 +18,11 @@
    we have an extended syntax with sections.
 *)
 type builtin_semgrepignore = Empty | Semgrep_scan_legacy
-type exclusion_mechanism = Gitignore_and_semgrepignore | Only_semgrepignore
+
+type exclusion_mechanism = {
+  use_gitignore_files : bool;
+  use_semgrepignore_files : bool;
+}
 
 (*
    Initialize the data used to filter paths.
diff --git a/src/targeting/Skip_target.ml b/src/targeting/Skip_target.ml
index 7a4849f63202..47a6e2bf7ee4 100644
--- a/src/targeting/Skip_target.ml
+++ b/src/targeting/Skip_target.ml
@@ -2,7 +2,8 @@
    Skip targets.
 *)
 open Common
-module Resp = Semgrep_output_v1_t
+open Fpath_.Operators
+module Out = Semgrep_output_v1_t
 
 (****************************************************************************)
 (* Minified files detection (via whitespace stats) *)
@@ -87,7 +88,7 @@ let is_minified (path : Fpath.t) =
       if stat.ws_freq < min_whitespace_frequency then
         Error
           {
-            Resp.path;
+            Out.path;
             reason = Minified;
             details =
               Some
@@ -100,7 +101,7 @@ let is_minified (path : Fpath.t) =
       else if stat.line_freq < min_line_frequency then
         Error
           {
-            Resp.path;
+            Out.path;
             reason = Minified;
             details =
               Some
@@ -133,7 +134,7 @@ let is_big max_bytes path =
   if max_bytes > 0 && size > max_bytes then
     Error
       {
-        Resp.path;
+        Out.path;
         reason = Too_big;
         details =
           Some
@@ -145,3 +146,60 @@ let is_big max_bytes path =
 let exclude_big_files max_target_bytes paths =
   let max_bytes = max_target_bytes in
   paths |> Result_.partition (is_big max_bytes)
+
+(*************************************************************************)
+(* Access permission filtering *)
+(*************************************************************************)
+(*
+   Filter out folders and files that don't have sufficient access permissions.
+
+   For Git projects, we only filter on regular files since folders are not
+   returned to us by 'git ls-files'. This is why semgrep won't report folders
+   with insufficient permissions for Git projects.
+
+   For other projects, we scan the file tree ourselves and need to check
+   folder permissions (read+execute on Unix, read on Windows).
+*)
+
+let skip_inaccessible_dir_path fpath : Out.skipped_target =
+  {
+    Out.path = fpath;
+    reason = Insufficient_permissions;
+    details = Some "folder lacks sufficient access permissions";
+    rule_id = None;
+  }
+
+let skip_inaccessible_file_path fpath : Out.skipped_target =
+  {
+    Out.path = fpath;
+    reason = Insufficient_permissions;
+    details = Some "file lacks sufficient access permissions";
+    rule_id = None;
+  }
+
+let dir_has_access_permissions (dir : Fpath.t) =
+  try
+    Unix.access !!dir [ R_OK; X_OK ];
+    true
+  with
+  | Unix.Unix_error _ -> false
+
+let file_has_access_permissions (file : Fpath.t) =
+  try
+    Unix.access !!file [ R_OK ];
+    true
+  with
+  | Unix.Unix_error _ -> false
+
+let filter_dir_access_permissions (dir : Fpath.t) :
+    (Fpath.t, Out.skipped_target) result =
+  if dir_has_access_permissions dir then Ok dir
+  else Error (skip_inaccessible_dir_path dir)
+
+let filter_file_access_permissions (file : Fpath.t) :
+    (Fpath.t, Out.skipped_target) result =
+  if file_has_access_permissions file then Ok file
+  else Error (skip_inaccessible_file_path file)
+
+let exclude_inaccessible_files files =
+  Result_.partition filter_file_access_permissions files
diff --git a/src/targeting/Skip_target.mli b/src/targeting/Skip_target.mli
index c91d58c5a32a..3155aefaf53e 100644
--- a/src/targeting/Skip_target.mli
+++ b/src/targeting/Skip_target.mli
@@ -20,3 +20,19 @@ val exclude_big_files :
 (* Detecting and filtering minified files (for Javascript) *)
 val exclude_minified_files :
   Fpath.t list -> Fpath.t list * Semgrep_output_v1_t.skipped_target list
+
+(*************************************************************************)
+(* Access permission filtering *)
+(*************************************************************************)
+(*
+   Filter out folders and files that don't have sufficient access permissions.
+*)
+
+val filter_dir_access_permissions :
+  Fpath.t -> (Fpath.t, Semgrep_output_v1_t.skipped_target) result
+
+val filter_file_access_permissions :
+  Fpath.t -> (Fpath.t, Semgrep_output_v1_t.skipped_target) result
+
+val exclude_inaccessible_files :
+  Fpath.t list -> Fpath.t list * Semgrep_output_v1_t.skipped_target list
diff --git a/src/targeting/Unit_semgrepignore.ml b/src/targeting/Unit_semgrepignore.ml
index 03a44103e635..f3509402b81f 100644
--- a/src/targeting/Unit_semgrepignore.ml
+++ b/src/targeting/Unit_semgrepignore.ml
@@ -32,7 +32,9 @@ let test_filter ?excludes:cli_patterns (files : F.t list) selection () =
       printf "--- Filtered files ---\n";
       let filter =
         Semgrepignore.create ?cli_patterns ~builtin_semgrepignore:Empty
-          ~exclusion_mechanism:Gitignore_and_semgrepignore ~project_root:root ()
+          ~exclusion_mechanism:
+            { use_gitignore_files = true; use_semgrepignore_files = true }
+          ~project_root:root ()
       in
       let error = ref false in
       selection


Test patch:
diff --git a/.github/workflows/build-test-windows-x86.jsonnet b/.github/workflows/build-test-windows-x86.jsonnet
index 7aa8b0f2b512..b2a650b49989 100644
--- a/.github/workflows/build-test-windows-x86.jsonnet
+++ b/.github/workflows/build-test-windows-x86.jsonnet
@@ -178,7 +178,7 @@ local build_wheels_job = {
       },
       run: |||
         tar xvfz artifacts.tgz
-        cp artifacts/semgrep-core cli/src/semgrep/bin
+        cp artifacts/* cli/src/semgrep/bin
         ./scripts/build-wheels.sh --plat-name win_amd64
       |||,
     },
diff --git a/.github/workflows/build-test-windows-x86.yml b/.github/workflows/build-test-windows-x86.yml
index 88d9e6502a13..b1471839e675 100644
--- a/.github/workflows/build-test-windows-x86.yml
+++ b/.github/workflows/build-test-windows-x86.yml
@@ -122,7 +122,7 @@ jobs:
           SEMGREP_FORCE_INSTALL: 1
         run: |
           tar xvfz artifacts.tgz
-          cp artifacts/semgrep-core cli/src/semgrep/bin
+          cp artifacts/* cli/src/semgrep/bin
           ./scripts/build-wheels.sh --plat-name win_amd64
       - uses: actions/upload-artifact@v3
         with:
diff --git a/.github/workflows/tests.jsonnet b/.github/workflows/tests.jsonnet
index 155c2d6aa85c..7e11689295ab 100644
--- a/.github/workflows/tests.jsonnet
+++ b/.github/workflows/tests.jsonnet
@@ -266,7 +266,7 @@ local test_cli_job = {
         unset CI
         unset "${!GITHUB_@}"
 
-        PYTEST_EXTRA_ARGS="--snapshot-update --allow-snapshot-deletion" make test
+        PYTEST_EXTRA_ARGS="--snapshot-update --allow-snapshot-deletion" make ci-test
       |||,
     },
   ] + snapshot_update_pr_steps(
@@ -527,7 +527,9 @@ local ignore_md = {
   permissions: gha.write_permissions,
   jobs: {
     'test-semgrep-core': test_semgrep_core_job,
-    'test-osemgrep': test_osemgrep_job,
+    // See semgrep/semgrep-proprietary/pull/2522 for why this is disabled and it
+    // will re-enable it
+    //'test-osemgrep': test_osemgrep_job,
     // Pysemgrep tests that require check-semgrep-pro
     'test-cli': test_cli_job,
     // Pysemgrep tests that require build-test-core-x86
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index 74f39c384020..a09cd9868774 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -146,7 +146,7 @@ jobs:
           unset CI
           unset "${!GITHUB_@}"
 
-          PYTEST_EXTRA_ARGS="--snapshot-update --allow-snapshot-deletion" make test
+          PYTEST_EXTRA_ARGS="--snapshot-update --allow-snapshot-deletion" make ci-test
         working-directory: cli
       - if: failure()
         name: Prepare repo for snapshot commit
@@ -196,39 +196,6 @@ jobs:
           - "3.9"
           - "3.10"
           - "3.11"
-  test-osemgrep:
-    container: returntocorp/ocaml:alpine-2024-01-18
-    env:
-      HOME: /root
-    runs-on: ubuntu-latest
-    steps:
-      - name: Make checkout speedy
-        run: git config --global fetch.parallel 50
-      - uses: actions/checkout@v3
-        with:
-          submodules: true
-      - name: Configure git safedir properly
-        run: git config --global --add safe.directory $(pwd)
-      - name: Build semgrep-core
-        run: |
-          eval $(opam env)
-          make install-deps-ALPINE-for-semgrep-core
-          make install-deps-for-semgrep-core
-          make core
-      - name: Install osemgrep
-        run: |
-          eval $(opam env)
-          make copy-core-for-cli
-      - name: Install Python dependencies
-        run: |
-          apk add --no-cache python3
-          pip install --no-cache-dir --ignore-installed distlib pipenv==2024.0.1
-          (cd cli; pipenv install --dev)
-      - name: Run pytest for osemgrep known passing tests
-        run: |
-          git config --global --add safe.directory "$(pwd)"
-          make osempass
-        working-directory: cli
   test-qa:
     name: quality assurance on semgrep
     needs:
diff --git a/cli/tests/conftest.py b/cli/tests/conftest.py
index ca7e0e0fbeec..0d02eba10f52 100644
--- a/cli/tests/conftest.py
+++ b/cli/tests/conftest.py
@@ -1,3 +1,18 @@
+# Define so-called pytest fixtures for our tests.
+#
+# Read about pytest's fixtures if you want to understand where test
+# function parameters come from. This is the introduction at
+# https://docs.pytest.org/en/latest/how-to/fixtures.html :
+#
+#   At a basic level, test functions request fixtures they require by
+#   declaring them as arguments.
+#
+#   When pytest goes to run a test, it looks at the parameters in that
+#   test function’s signature, and then searches for fixtures that have
+#   the same names as those parameters. Once pytest finds them, it runs
+#   those fixtures, captures what they returned (if anything), and passes
+#   those objects into the test function as arguments.
+#
 ##############################################################################
 # Prelude
 ##############################################################################
@@ -361,15 +376,17 @@ def __iter__(self):
         yield self.stderr
 
 
+# Implements the 'RunSemgrep' function type (type checking is done
+# right after this definition) defined in 'fixtures.py'
+#
 def _run_semgrep(
-    # if you change these args, mypy will require updating tests.fixtures.RunSemgrep too
     config: Optional[Union[str, Path, List[str]]] = None,
     *,
-    target_name: Optional[str] = "basic",
+    target_name: Optional[str] = None,
     subcommand: Optional[str] = None,
-    options: Optional[List[Union[str, Path]]] = None,
-    output_format: Optional[OutputFormat] = OutputFormat.JSON,
-    strict: bool = True,
+    options: Optional[List[str]] = None,
+    output_format: Optional[OutputFormat] = None,
+    strict: bool = False,
     quiet: bool = False,
     env: Optional[Dict[str, str]] = None,
     assert_exit_code: Union[None, int, Set[int]] = 0,
@@ -379,6 +396,8 @@ def _run_semgrep(
     stdin: Optional[str] = None,
     clean_fingerprint: bool = True,
     use_click_runner: bool = False,  # Deprecated! see semgrep_runner.py toplevel comment
+    prepare_workspace: Callable[[], None] = lambda: None,
+    teardown_workspace: Callable[[], None] = lambda: None,
 ) -> SemgrepResult:
     """Run the semgrep CLI.
 
@@ -390,91 +409,125 @@ def _run_semgrep(
     :param settings_file: what setting file for semgrep to use. If None, a random temp file is generated
                           with default params for anonymous_user_id and has_shown_metrics_notification
     """
-    env = {} if not env else env.copy()
-
-    if force_color:
-        env["SEMGREP_FORCE_COLOR"] = "true"
-        # NOTE: We should also apply the known color flags to the env
-        env["FORCE_COLOR"] = "1"
-        if "NO_COLOR" in env:
-            del env["NO_COLOR"]
-
-    if "SEMGREP_USER_AGENT_APPEND" not in env:
-        env["SEMGREP_USER_AGENT_APPEND"] = "pytest"
-
-    # If delete_setting_file is false and a settings file doesnt exist, put a default
-    # as we are not testing said setting. Note that if Settings file exists we want to keep it
-    # Use a unique settings file so multithreaded pytest works well
-    if "SEMGREP_SETTINGS_FILE" not in env:
-        unique_settings_file = tempfile.NamedTemporaryFile().name
-        make_settings_file(Path(unique_settings_file))
-        env["SEMGREP_SETTINGS_FILE"] = unique_settings_file
-    if "SEMGREP_VERSION_CACHE_PATH" not in env:
-        env["SEMGREP_VERSION_CACHE_PATH"] = tempfile.TemporaryDirectory().name
-    if "SEMGREP_ENABLE_VERSION_CHECK" not in env:
-        env["SEMGREP_ENABLE_VERSION_CHECK"] = "0"
-    if force_metrics_off and "SEMGREP_SEND_METRICS" not in env:
-        env["SEMGREP_SEND_METRICS"] = "off"
-
-    if options is None:
-        options = []
-
-    if strict:
-        options.append("--strict")
-
-    if quiet:
-        options.append("--quiet")
-
-    if config is not None:
-        if isinstance(config, list):
-            for conf in config:
-                options.extend(["--config", conf])
-        else:
-            options.extend(["--config", config])
-
-    if output_format == OutputFormat.JSON:
-        options.append("--json")
-    elif output_format == OutputFormat.GITLAB_SAST:
-        options.append("--gitlab-sast")
-    elif output_format == OutputFormat.GITLAB_SECRETS:
-        options.append("--gitlab-secrets")
-    elif output_format == OutputFormat.JUNIT_XML:
-        options.append("--junit-xml")
-    elif output_format == OutputFormat.SARIF:
-        options.append("--sarif")
-
-    targets = []
-    if target_name is not None:
-        targets.append(
-            Path("targets") / target_name if assume_targets_dir else Path(target_name)
+    prepare_workspace()
+
+    # ensure teardown_workspace is called at the end
+    try:
+        env = {} if not env else env.copy()
+
+        if force_color:
+            env["SEMGREP_FORCE_COLOR"] = "true"
+            # NOTE: We should also apply the known color flags to the env
+            env["FORCE_COLOR"] = "1"
+            if "NO_COLOR" in env:
+                del env["NO_COLOR"]
+
+        if "SEMGREP_USER_AGENT_APPEND" not in env:
+            env["SEMGREP_USER_AGENT_APPEND"] = "pytest"
+
+        # If delete_setting_file is false and a settings file doesnt exist, put a default
+        # as we are not testing said setting. Note that if Settings file exists we want to keep it
+        # Use a unique settings file so multithreaded pytest works well
+        if "SEMGREP_SETTINGS_FILE" not in env:
+            unique_settings_file = tempfile.NamedTemporaryFile().name
+            make_settings_file(Path(unique_settings_file))
+            env["SEMGREP_SETTINGS_FILE"] = unique_settings_file
+        if "SEMGREP_VERSION_CACHE_PATH" not in env:
+            env["SEMGREP_VERSION_CACHE_PATH"] = tempfile.TemporaryDirectory().name
+        if "SEMGREP_ENABLE_VERSION_CHECK" not in env:
+            env["SEMGREP_ENABLE_VERSION_CHECK"] = "0"
+        if force_metrics_off and "SEMGREP_SEND_METRICS" not in env:
+            env["SEMGREP_SEND_METRICS"] = "off"
+
+        if options is None:
+            options = []
+
+        if strict:
+            options.append("--strict")
+
+        if quiet:
+            options.append("--quiet")
+
+        if config is not None:
+            if isinstance(config, list):
+                for conf in config:
+                    options.extend(["--config", conf])
+            else:
+                options.extend(["--config", str(config)])
+
+        if output_format == OutputFormat.JSON:
+            options.append("--json")
+        elif output_format == OutputFormat.GITLAB_SAST:
+            options.append("--gitlab-sast")
+        elif output_format == OutputFormat.GITLAB_SECRETS:
+            options.append("--gitlab-secrets")
+        elif output_format == OutputFormat.JUNIT_XML:
+            options.append("--junit-xml")
+        elif output_format == OutputFormat.SARIF:
+            options.append("--sarif")
+
+        targets = []
+        if target_name is not None:
+            targets.append(
+                Path("targets") / target_name
+                if assume_targets_dir
+                else Path(target_name)
+            )
+        args = " ".join(shlex.quote(str(c)) for c in [*options, *targets])
+        env_string = " ".join(f'{k}="{v}"' for k, v in env.items())
+
+        runner = SemgrepRunner(
+            env=env, mix_stderr=False, use_click_runner=use_click_runner
         )
-    args = " ".join(shlex.quote(str(c)) for c in [*options, *targets])
-    env_string = " ".join(f'{k}="{v}"' for k, v in env.items())
-
-    runner = SemgrepRunner(env=env, mix_stderr=False, use_click_runner=use_click_runner)
-    click_result = runner.invoke(cli, subcommand=subcommand, args=args, input=stdin)
-    subcommand_prefix = f"{subcommand} " if subcommand else ""
-    result = SemgrepResult(
-        # the actual executable was either semgrep or osemgrep. Is it bad?
-        f"{env_string} semgrep {subcommand_prefix}{args}",
-        click_result.stdout,
-        click_result.stderr,
-        click_result.exit_code,
-        clean_fingerprint,
-    )
-    result.print_debug_info()
+        click_result = runner.invoke(cli, subcommand=subcommand, args=args, input=stdin)
+        subcommand_prefix = f"{subcommand} " if subcommand else ""
+        result = SemgrepResult(
+            # the actual executable was either semgrep or osemgrep. Is it bad?
+            f"{env_string} semgrep {subcommand_prefix}{args}",
+            click_result.stdout,
+            click_result.stderr,
+            click_result.exit_code,
+            clean_fingerprint,
+        )
+        result.print_debug_info()
 
-    if isinstance(assert_exit_code, set):
-        assert result.exit_code in assert_exit_code
-    elif isinstance(assert_exit_code, int):
-        assert result.exit_code == assert_exit_code
+        if isinstance(assert_exit_code, set):
+            assert result.exit_code in assert_exit_code
+        elif isinstance(assert_exit_code, int):
+            assert result.exit_code == assert_exit_code
 
-    return result
+        return result
+    finally:
+        teardown_workspace()
 
 
 ##############################################################################
 # Fixtures
 ##############################################################################
+#
+# Warning to naive programmers:
+#
+#   # here's some utility function that we want to make available to tests:
+#   @pytest.fixture
+#   def foo():
+#       print("hello")
+#
+#   # here's a test in some other test file:
+#   def test_whatever(foo):
+#       foo()
+#
+# causes pytest to call test_whatever(foo) for us! If you're paying
+# attention, you'll notice that the following is not equivalent and won't
+# work because we didn't define a 'bar' fixture:
+#
+#   def test_whatever(bar):
+#       bar()
+#
+
+
+@pytest.fixture
+def run_semgrep() -> fixtures.RunSemgrep:
+    return _run_semgrep
 
 
 @pytest.fixture()
@@ -486,9 +539,10 @@ def unique_home_dir(monkeypatch: pytest.MonkeyPatch, tmp_path: Path):
     yield tmp_path
 
 
-@pytest.fixture
-def run_semgrep() -> fixtures.RunSemgrep:
-    return partial(_run_semgrep, strict=False, target_name=None, output_format=None)
+# Provide a run_semgrep function with alternate defaults
+_run_strict_semgrep_on_basic_targets_with_json_output: fixtures.RunSemgrep = partial(
+    _run_semgrep, strict=True, target_name="basic", output_format=OutputFormat.JSON
+)
 
 
 @pytest.fixture
@@ -502,12 +556,13 @@ def run_semgrep_in_tmp(
     (tmp_path / "rules").symlink_to(RULES_PATH.resolve())
     monkeypatch.chdir(tmp_path)
 
-    return _run_semgrep
+    return _run_strict_semgrep_on_basic_targets_with_json_output
 
 
 @pytest.fixture
 def run_semgrep_on_copied_files(
-    monkeypatch: pytest.MonkeyPatch, tmp_path: Path
+    monkeypatch: pytest.MonkeyPatch,
+    tmp_path: Path,
 ) -> fixtures.RunSemgrep:
     """
     Like run_semgrep_in_tmp, but fully copies rule and target data to avoid
@@ -517,7 +572,7 @@ def run_semgrep_on_copied_files(
     copytree(RULES_PATH.resolve(), tmp_path / "rules")
     monkeypatch.chdir(tmp_path)
 
-    return _run_semgrep
+    return _run_strict_semgrep_on_basic_targets_with_json_output
 
 
 @pytest.fixture
diff --git a/cli/tests/default/e2e-other/test_ci.py b/cli/tests/default/e2e-other/test_ci.py
index 8219a211077e..285a6126159c 100644
--- a/cli/tests/default/e2e-other/test_ci.py
+++ b/cli/tests/default/e2e-other/test_ci.py
@@ -444,6 +444,11 @@ def enable_dependency_query() -> bool:
     return False
 
 
+@pytest.fixture
+def always_suppress_errors(mocker):
+    mocker.patch.object(ScanHandler, "always_suppress_errors", False)
+
+
 # Defining these fixtures to return functions allows the hostname (SEMGREP_URL) to be set for each test,
 # permitting tests that use different base URLs to succeed using these mocks.
 @pytest.fixture
@@ -2818,3 +2823,48 @@ def test_existing_reachable_finding_deduplication(
     )
     findings_json = upload_results_mock.last_request.json()
     assert len(findings_json["findings"]) == 0
+
+
+@pytest.mark.parametrize("always_suppress_errors", [True, False], indirect=True)
+@pytest.mark.parametrize(
+    "scan_config",
+    [
+        dedent(
+            """
+        rules:
+        - id: eqeq-bad
+          pattern: $X == $X
+          message: "useless comparison"
+          languages: [python]
+          severity: ERROR
+          metadata:
+            source: https://semgrep.dev/r/eqeq-bad
+        - id: pattern-parse-error
+          pattern: $X ==
+          message: "useless comparison to 5"
+          languages: [python]
+          severity: ERROR
+     """
+        )
+    ],
+)
+@pytest.mark.osemfail
+def test_always_suppress_errors(
+    run_semgrep: RunSemgrep,
+    snapshot,
+    start_scan_mock_maker,
+    complete_scan_mock_maker,
+    upload_results_mock_maker,
+    always_suppress_errors,
+):
+    start_scan_mock = start_scan_mock_maker("https://semgrep.dev")
+    complete_scan_mock = complete_scan_mock_maker("https://semgrep.dev")
+    upload_results_mock = upload_results_mock_maker("https://semgrep.dev")
+
+    result = run_semgrep(
+        options=["ci", "--oss-only", "--no-suppress-errors"],
+        strict=False,
+        assert_exit_code=0 if always_suppress_errors else 2,
+        env={"SEMGREP_APP_TOKEN": "fake_key"},
+        use_click_runner=True,
+    )
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/autofix/add-metadata-hcl.hcl-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/autofix/add-metadata-hcl.hcl-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/autofix/add-metadata-hcl.hcl-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/autofix/add-metadata-hcl.hcl-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/autofix/add-metadata-hcl.hcl-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-dryrun/autofix/autofix.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-dryrun/autofix/autofix.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-dryrun/autofix/autofix.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-dryrun/autofix/autofix.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-not-dryrun/autofix/autofix.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-not-dryrun/autofix/autofix.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-not-dryrun/autofix/autofix.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-not-dryrun/autofix/autofix.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-dryrun/autofix/csv-writer.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-dryrun/autofix/csv-writer.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-dryrun/autofix/csv-writer.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-dryrun/autofix/csv-writer.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-not-dryrun/autofix/csv-writer.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-not-dryrun/autofix/csv-writer.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-not-dryrun/autofix/csv-writer.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-not-dryrun/autofix/csv-writer.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/autofix/defaulthttpclient.java-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/autofix/defaulthttpclient.java-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/autofix/defaulthttpclient.java-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/autofix/defaulthttpclient.java-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/autofix/defaulthttpclient.java-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/autofix/defaulthttpclient.java-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/autofix/defaulthttpclient.java-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/autofix/defaulthttpclient.java-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/autofix/delete-partial-line.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/autofix/delete-partial-line.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/autofix/delete-partial-line.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/autofix/delete-partial-line.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/autofix/delete-partial-line.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/autofix/delete-partial-line.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/autofix/delete-partial-line.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/autofix/delete-partial-line.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/autofix/django-none-password-default.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/autofix/django-none-password-default.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/autofix/django-none-password-default.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/autofix/django-none-password-default.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/autofix/django-none-password-default.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/autofix/django-none-password-default.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/autofix/django-none-password-default.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/autofix/django-none-password-default.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/autofix/flask-use-jsonify.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/autofix/flask-use-jsonify.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/autofix/flask-use-jsonify.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/autofix/flask-use-jsonify.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/autofix/flask-use-jsonify.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/autofix/flask-use-jsonify.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/autofix/flask-use-jsonify.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/autofix/flask-use-jsonify.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-dryrun/autofix/imported-entity.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-dryrun/autofix/imported-entity.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-dryrun/autofix/imported-entity.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-dryrun/autofix/imported-entity.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-not-dryrun/autofix/imported-entity.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-not-dryrun/autofix/imported-entity.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-not-dryrun/autofix/imported-entity.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-not-dryrun/autofix/imported-entity.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/autofix/java-string-wrap.java-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/autofix/java-string-wrap.java-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/autofix/java-string-wrap.java-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/autofix/java-string-wrap.java-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/autofix/java-string-wrap.java-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/autofix/java-string-wrap.java-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/autofix/java-string-wrap.java-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/autofix/java-string-wrap.java-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/autofix/ocaml_paren_expr.ml-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/autofix/ocaml_paren_expr.ml-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/autofix/ocaml_paren_expr.ml-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/autofix/ocaml_paren_expr.ml-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/autofix/ocaml_paren_expr.ml-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-dryrun/autofix/collision.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-not-dryrun/autofix/collision.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/autofix/python-assert-statement.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/autofix/python-assert-statement.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/autofix/python-assert-statement.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/autofix/python-assert-statement.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/autofix/python-assert-statement.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/autofix/python-assert-statement.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/autofix/python-assert-statement.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/autofix/python-assert-statement.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-dryrun/autofix/python-delete-import.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-dryrun/autofix/python-delete-import.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-dryrun/autofix/python-delete-import.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-dryrun/autofix/python-delete-import.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/autofix/python-delete-import.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/autofix/python-delete-import.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/autofix/python-delete-import.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/autofix/python-delete-import.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-dryrun/autofix/python-ranges.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-dryrun/autofix/python-ranges.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-dryrun/autofix/python-ranges.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-dryrun/autofix/python-ranges.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-not-dryrun/autofix/python-ranges.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-not-dryrun/autofix/python-ranges.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-not-dryrun/autofix/python-ranges.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-not-dryrun/autofix/python-ranges.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-dryrun/autofix/redundant.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-dryrun/autofix/redundant.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-dryrun/autofix/redundant.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-dryrun/autofix/redundant.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-not-dryrun/autofix/redundant.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-not-dryrun/autofix/redundant.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-not-dryrun/autofix/redundant.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-not-dryrun/autofix/redundant.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/autofix/replace-field-yaml.yaml-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/autofix/replace-field-yaml.yaml-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/autofix/replace-field-yaml.yaml-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/autofix/replace-field-yaml.yaml-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/autofix/replace-field-yaml.yaml-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/autofix/replace-field-yaml.yaml-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/autofix/replace-field-yaml.yaml-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/autofix/replace-field-yaml.yaml-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/autofix/requests-use-timeout.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/autofix/requests-use-timeout.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/autofix/requests-use-timeout.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/autofix/requests-use-timeout.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/autofix/requests-use-timeout.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/autofix/requests-use-timeout.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/autofix/requests-use-timeout.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/autofix/requests-use-timeout.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-dryrun/autofix/three-autofixes.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-dryrun/autofix/three-autofixes.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-dryrun/autofix/three-autofixes.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-dryrun/autofix/three-autofixes.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/autofix/three-autofixes.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/autofix/three-autofixes.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/autofix/three-autofixes.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/autofix/three-autofixes.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-dryrun/autofix/two-autofixes.txt-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-dryrun/autofix/two-autofixes.txt-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-dryrun/autofix/two-autofixes.txt-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-dryrun/autofix/two-autofixes.txt-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/autofix/two-autofixes.txt-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/autofix/two-autofixes.txt-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/autofix/two-autofixes.txt-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/autofix/two-autofixes.txt-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-dryrun/autofix/utf-8.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-dryrun/autofix/utf-8.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-dryrun/autofix/utf-8.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-dryrun/autofix/utf-8.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-not-dryrun/autofix/utf-8.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-not-dryrun/autofix/utf-8.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-not-dryrun/autofix/utf-8.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-not-dryrun/autofix/utf-8.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-not-dryrun/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-not-dryrun/results.json
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-json-not-dryrun/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun
new file mode 100644
index 000000000000..dd2b74360187
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-dryrun/autofix/order.py-dryrun
@@ -0,0 +1,12 @@
+# Several findings will overlap in the following array. The autofixes
+# must be applied in the order in which the findings are discovered or
+# in which they are reported (which is it?).
+
+a
+b
+c
+d
+e
+f
+g
+h
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-dryrun/results.json
new file mode 100644
index 000000000000..b6d102e4d65a
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-dryrun/results.json
@@ -0,0 +1,178 @@
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/autofix/order.py"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.autofix.slice-ab",
+      "end": {
+        "col": 2,
+        "line": 6,
+        "offset": 190
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_a_to_b_OK",
+        "fixed_lines": [
+          "replaced_lines_a_to_b_OK"
+        ],
+        "is_ignored": false,
+        "lines": "a\nb",
+        "message": "slice a-b matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 5,
+        "offset": 187
+      }
+    },
+    {
+      "check_id": "rules.autofix.a-slice-bc",
+      "end": {
+        "col": 2,
+        "line": 7,
+        "offset": 192
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_b_to_c_BAD",
+        "is_ignored": false,
+        "lines": "b\nc",
+        "message": "slice b-c matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 6,
+        "offset": 189
+      }
+    },
+    {
+      "check_id": "rules.autofix.z-slice-bc",
+      "end": {
+        "col": 2,
+        "line": 7,
+        "offset": 192
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_b_to_c_OK",
+        "is_ignored": false,
+        "lines": "b\nc",
+        "message": "slice b-c matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 6,
+        "offset": 189
+      }
+    },
+    {
+      "check_id": "rules.autofix.slice-df",
+      "end": {
+        "col": 2,
+        "line": 10,
+        "offset": 198
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_d_to_f_OK",
+        "fixed_lines": [
+          "replaced_lines_d_to_f_OK"
+        ],
+        "is_ignored": false,
+        "lines": "d\ne\nf",
+        "message": "slice d-f matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 8,
+        "offset": 193
+      }
+    },
+    {
+      "check_id": "rules.autofix.slice-eg",
+      "end": {
+        "col": 2,
+        "line": 11,
+        "offset": 200
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_e_to_g_BAD",
+        "is_ignored": false,
+        "lines": "e\nf\ng",
+        "message": "slice e-g matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 9,
+        "offset": 195
+      }
+    },
+    {
+      "check_id": "rules.autofix.slice-gh",
+      "end": {
+        "col": 2,
+        "line": 12,
+        "offset": 202
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_g_to_h_OK",
+        "fixed_lines": [
+          "replaced_lines_g_to_h_OK"
+        ],
+        "is_ignored": false,
+        "lines": "g\nh",
+        "message": "slice g-h matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 11,
+        "offset": 199
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed
new file mode 100644
index 000000000000..27de1b1b1176
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-not-dryrun/autofix/order.py-fixed
@@ -0,0 +1,8 @@
+# Several findings will overlap in the following array. The autofixes
+# must be applied in the order in which the findings are discovered or
+# in which they are reported (which is it?).
+
+replaced_lines_a_to_b_OK
+c
+replaced_lines_d_to_f_OK
+replaced_lines_g_to_h_OK
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-not-dryrun/results.json b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-not-dryrun/results.json
new file mode 100644
index 000000000000..bdfce9f1af40
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_json_output_osemfail/order.yaml-order.py-json-not-dryrun/results.json
@@ -0,0 +1,169 @@
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/autofix/order.py"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.autofix.slice-ab",
+      "end": {
+        "col": 2,
+        "line": 6,
+        "offset": 190
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_a_to_b_OK",
+        "is_ignored": false,
+        "lines": "a\nb",
+        "message": "slice a-b matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 5,
+        "offset": 187
+      }
+    },
+    {
+      "check_id": "rules.autofix.a-slice-bc",
+      "end": {
+        "col": 2,
+        "line": 7,
+        "offset": 192
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_b_to_c_BAD",
+        "is_ignored": false,
+        "lines": "b\nc",
+        "message": "slice b-c matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 6,
+        "offset": 189
+      }
+    },
+    {
+      "check_id": "rules.autofix.z-slice-bc",
+      "end": {
+        "col": 2,
+        "line": 7,
+        "offset": 192
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_b_to_c_OK",
+        "is_ignored": false,
+        "lines": "b\nc",
+        "message": "slice b-c matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 6,
+        "offset": 189
+      }
+    },
+    {
+      "check_id": "rules.autofix.slice-df",
+      "end": {
+        "col": 2,
+        "line": 10,
+        "offset": 198
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_d_to_f_OK",
+        "is_ignored": false,
+        "lines": "d\ne\nf",
+        "message": "slice d-f matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 8,
+        "offset": 193
+      }
+    },
+    {
+      "check_id": "rules.autofix.slice-eg",
+      "end": {
+        "col": 2,
+        "line": 11,
+        "offset": 200
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_e_to_g_BAD",
+        "is_ignored": false,
+        "lines": "e\nf\ng",
+        "message": "slice e-g matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 9,
+        "offset": 195
+      }
+    },
+    {
+      "check_id": "rules.autofix.slice-gh",
+      "end": {
+        "col": 2,
+        "line": 12,
+        "offset": 202
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "fix": "replaced_lines_g_to_h_OK",
+        "is_ignored": false,
+        "lines": "g\nh",
+        "message": "slice g-h matches",
+        "metadata": {},
+        "metavars": {},
+        "severity": "WARNING",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/autofix/order.py",
+      "start": {
+        "col": 1,
+        "line": 11,
+        "offset": 199
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun
new file mode 100644
index 000000000000..61fd22c0d45c
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/autofix/add-metadata-hcl.hcl-dryrun
@@ -0,0 +1,9 @@
+resource "aws_instance" "example1" {
+  ami           = "ami-005e54dee72cc1d01"
+  instance_type = "t2.micro"
+}
+
+resource "aws_instance" "example2" {
+  ami           = "ami-005e54dee72cc1d02"
+  instance_type = "t2.micro"
+}
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
new file mode 100644
index 000000000000..0f47a4327573
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/autofix/add-metadata-hcl.hcl-fixed
@@ -0,0 +1,17 @@
+resource "aws_instance" "example1" {
+  ami           = "ami-005e54dee72cc1d01"
+  instance_type = "t2.micro"
+
+  metadata_options {
+    http_tokens = "required"
+  }
+}
+
+resource "aws_instance" "example2" {
+  ami           = "ami-005e54dee72cc1d02"
+  instance_type = "t2.micro"
+
+  metadata_options {
+    http_tokens = "required"
+  }
+}
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/add-metadata-hcl.yaml-add-metadata-hcl.hcl-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun
new file mode 100644
index 000000000000..2b88240320bd
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/autofix/autofix.py-dryrun
@@ -0,0 +1,7 @@
+import sys
+
+inputs = {}
+for x in range(5):
+  inputs[x] = 1
+  if inputs[x + 1] == True:
+    exit(1)
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed
new file mode 100644
index 000000000000..89734420b910
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/autofix/autofix.py-fixed
@@ -0,0 +1,7 @@
+import sys
+
+inputs = {}
+for x in range(5):
+  inputs.get(x) = 1
+  if inputs.get((x + 1)) == True:
+    exit(1)
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/autofix.yaml-autofix.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/autofix.yaml-autofix.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun
new file mode 100644
index 000000000000..6e194f3dffba
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/autofix/csv-writer.py-dryrun
@@ -0,0 +1,15 @@
+import csv
+
+csvfile="/tmp/blah.csv"
+
+def get_file():
+    return csvfile
+
+# ruleid:unquoted-csv-writer
+csv.writer(csvfile, delimiter=',', quotechar='"')
+
+# ruleid:unquoted-csv-writer
+csv.writer(get_file(), delimiter=',', quotechar='"')
+
+# ok
+csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL)
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed
new file mode 100644
index 000000000000..527f915cf84a
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/autofix/csv-writer.py-fixed
@@ -0,0 +1,15 @@
+import csv
+
+csvfile="/tmp/blah.csv"
+
+def get_file():
+    return csvfile
+
+# ruleid:unquoted-csv-writer
+csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL)
+
+# ruleid:unquoted-csv-writer
+csv.writer(get_file(), delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL)
+
+# ok
+csv.writer(csvfile, delimiter=',', quotechar='"', quoting=csv.QUOTE_ALL)
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/csv-writer.yaml-csv-writer.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/csv-writer.yaml-csv-writer.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun
new file mode 100644
index 000000000000..65606ca44948
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/autofix/defaulthttpclient.java-dryrun
@@ -0,0 +1,31 @@
+// cf. https://mkyong.com/java/the-type-defaulthttpclient-is-deprecated/
+
+package com.exampleweb.controller;
+
+import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.DefaultHttpClient;
+
+public class WebCrawler {
+
+	public void crawl(String[] args) throws Exception {
+        // ruleid: defaulthttpclient-is-deprecated
+		HttpClient client = new DefaultHttpClient();
+		HttpGet request = new HttpGet("http://google.com");
+		HttpResponse response = client.execute(request);
+	}
+
+}
+
+public class SecureWebCrawler {
+
+	public void crawl(String[] args) throws Exception {
+        // ok
+		HttpClient client = new SystemDefaultHttpClient();
+		HttpGet request = new HttpGet("http://google.com");
+		HttpResponse response = client.execute(request);
+	}
+
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed
new file mode 100644
index 000000000000..3b995a9867e9
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/autofix/defaulthttpclient.java-fixed
@@ -0,0 +1,31 @@
+// cf. https://mkyong.com/java/the-type-defaulthttpclient-is-deprecated/
+
+package com.exampleweb.controller;
+
+import org.apache.http.Header;
+import org.apache.http.HttpResponse;
+import org.apache.http.client.HttpClient;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.impl.client.DefaultHttpClient;
+
+public class WebCrawler {
+
+	public void crawl(String[] args) throws Exception {
+        // ruleid: defaulthttpclient-is-deprecated
+		HttpClient client = new SystemDefaultHttpClient();
+		HttpGet request = new HttpGet("http://google.com");
+		HttpResponse response = client.execute(request);
+	}
+
+}
+
+public class SecureWebCrawler {
+
+	public void crawl(String[] args) throws Exception {
+        // ok
+		HttpClient client = new SystemDefaultHttpClient();
+		HttpGet request = new HttpGet("http://google.com");
+		HttpResponse response = client.execute(request);
+	}
+
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/defaulthttpclient.yaml-defaulthttpclient.java-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun
new file mode 100644
index 000000000000..db2b3aad454f
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/autofix/delete-partial-line.py-dryrun
@@ -0,0 +1,23 @@
+def foo(x=42):
+    print(x)
+
+# No autofix
+foo()
+foo(
+)
+foo(43)
+foo(
+43
+)
+
+# Autofix of a partial line
+foo(42)
+foo(
+42)
+foo(42
+)
+
+# Autofix of an entire line
+foo(
+42
+)
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed
new file mode 100644
index 000000000000..3c4015290e99
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/autofix/delete-partial-line.py-fixed
@@ -0,0 +1,22 @@
+def foo(x=42):
+    print(x)
+
+# No autofix
+foo()
+foo(
+)
+foo(43)
+foo(
+43
+)
+
+# Autofix of a partial line
+foo()
+foo(
+)
+foo(
+)
+
+# Autofix of an entire line
+foo(
+)
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/delete-partial-line.yaml-delete-partial-line.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun
new file mode 100644
index 000000000000..674538f69442
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/autofix/django-none-password-default.py-dryrun
@@ -0,0 +1,81 @@
+from django.contrib import auth
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.utils.translation import gettext as _
+from django.views.decorators.csrf import csrf_protect
+from rest_framework import status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.response import Response
+
+from ...conf import settings
+from ...core.decorators import require_dict_data
+from ...core.mail import mail_user
+from ..bans import get_user_ban
+from ..forms.auth import AuthenticationForm, ResendActivationForm, ResetPasswordForm
+from ..serializers import AnonymousUserSerializer, AuthenticatedUserSerializer
+from ..tokens import (
+    is_password_change_token_valid,
+    make_activation_token,
+    make_password_change_token,
+)
+from .rest_permissions import UnbannedAnonOnly, UnbannedOnly
+
+User = auth.get_user_model()
+BaseUserManager = User.__class__
+
+class PasswordChangeFailed(Exception):
+    pass
+
+def change_forgotten_password(request, pk, token):
+    """
+    POST /auth/change-password/user/token/ with CSRF and new password
+    will change forgotten password
+    """
+    if request.settings.enable_sso:
+        raise PermissionDenied(_("Please use the 3rd party site to authenticate."))
+
+    invalid_message = _("Form link is invalid. Please try again.")
+    expired_message = _("Your link has expired. Please request new one.")
+
+    try:
+        try:
+            user = User.objects.get(pk=pk, is_active=True)
+        except User.DoesNotExist:
+            raise PasswordChangeFailed(invalid_message)
+
+        if request.user.is_authenticated and request.user.id != user.id:
+            raise PasswordChangeFailed(invalid_message)
+        if not is_password_change_token_valid(user, token):
+            raise PasswordChangeFailed(invalid_message)
+
+        if user.requires_activation:
+            raise PasswordChangeFailed(expired_message)
+        if get_user_ban(user, request.cache_versions):
+            raise PasswordChangeFailed(expired_message)
+    except PasswordChangeFailed as e:
+        return Response({"detail": e.args[0]}, status=status.HTTP_400_BAD_REQUEST)
+
+    try:
+        # ruleid: use-none-for-password-default
+        new_password = request.data.get("password", "")
+        validate_password(new_password, user=user)
+        user.set_password(new_password)
+        user.save()
+    except ValidationError as e:
+        return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
+
+    return Response({"username": user.username})
+
+class UserManager(BaseUserManager):
+    # ruleid: use-none-for-password-default
+    def create_user(self, email, password=""):
+        """
+        Creates and saves a Poster with the given email and password.
+        """
+        if not email:
+            raise ValueError('Users must have an email address')
+
+        user = self.model(email=self.normalize_email(email))
+        user.set_password(password)
+        user.save(using=self._db)
+        return user
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed
new file mode 100644
index 000000000000..986f15971678
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/autofix/django-none-password-default.py-fixed
@@ -0,0 +1,81 @@
+from django.contrib import auth
+from django.contrib.auth.password_validation import validate_password
+from django.core.exceptions import PermissionDenied, ValidationError
+from django.utils.translation import gettext as _
+from django.views.decorators.csrf import csrf_protect
+from rest_framework import status
+from rest_framework.decorators import api_view, permission_classes
+from rest_framework.response import Response
+
+from ...conf import settings
+from ...core.decorators import require_dict_data
+from ...core.mail import mail_user
+from ..bans import get_user_ban
+from ..forms.auth import AuthenticationForm, ResendActivationForm, ResetPasswordForm
+from ..serializers import AnonymousUserSerializer, AuthenticatedUserSerializer
+from ..tokens import (
+    is_password_change_token_valid,
+    make_activation_token,
+    make_password_change_token,
+)
+from .rest_permissions import UnbannedAnonOnly, UnbannedOnly
+
+User = auth.get_user_model()
+BaseUserManager = User.__class__
+
+class PasswordChangeFailed(Exception):
+    pass
+
+def change_forgotten_password(request, pk, token):
+    """
+    POST /auth/change-password/user/token/ with CSRF and new password
+    will change forgotten password
+    """
+    if request.settings.enable_sso:
+        raise PermissionDenied(_("Please use the 3rd party site to authenticate."))
+
+    invalid_message = _("Form link is invalid. Please try again.")
+    expired_message = _("Your link has expired. Please request new one.")
+
+    try:
+        try:
+            user = User.objects.get(pk=pk, is_active=True)
+        except User.DoesNotExist:
+            raise PasswordChangeFailed(invalid_message)
+
+        if request.user.is_authenticated and request.user.id != user.id:
+            raise PasswordChangeFailed(invalid_message)
+        if not is_password_change_token_valid(user, token):
+            raise PasswordChangeFailed(invalid_message)
+
+        if user.requires_activation:
+            raise PasswordChangeFailed(expired_message)
+        if get_user_ban(user, request.cache_versions):
+            raise PasswordChangeFailed(expired_message)
+    except PasswordChangeFailed as e:
+        return Response({"detail": e.args[0]}, status=status.HTTP_400_BAD_REQUEST)
+
+    try:
+        # ruleid: use-none-for-password-default
+        new_password = request.data.get("password", None)
+        validate_password(new_password, user=user)
+        user.set_password(new_password)
+        user.save()
+    except ValidationError as e:
+        return Response({"detail": e.messages[0]}, status=status.HTTP_400_BAD_REQUEST)
+
+    return Response({"username": user.username})
+
+class UserManager(BaseUserManager):
+    # ruleid: use-none-for-password-default
+    def create_user(self, email, password=None):
+        """
+        Creates and saves a Poster with the given email and password.
+        """
+        if not email:
+            raise ValueError('Users must have an email address')
+
+        user = self.model(email=self.normalize_email(email))
+        user.set_password(password)
+        user.save(using=self._db)
+        return user
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/django-none-password-default.yaml-django-none-password-default.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
new file mode 100644
index 000000000000..996d098d84f6
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
@@ -0,0 +1,5 @@
+
+a = 1
+b = 2
+c = 3
+d = 4
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
new file mode 100644
index 000000000000..9d305fb6b063
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
@@ -0,0 +1,5 @@
+
+a = once
+b = 2
+c = 3
+d = 4
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/exact-collision.yaml-collision.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/exact-collision.yaml-collision.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun
new file mode 100644
index 000000000000..65acefe35182
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/autofix/flask-use-jsonify.py-dryrun
@@ -0,0 +1,24 @@
+## Normal import
+import flask
+import json
+app = flask.Flask(__name__)
+
+@app.route("/user")
+def user():
+    user_dict = get_user(request.args.get("id"))
+    # ruleid:use-jsonify
+    return json.dumps(user_dict)
+
+from json import dumps
+
+@app.route("/user")
+def user():
+    user_dict = get_user(request.args.get("id"))
+    # ruleid:use-jsonify
+    return dumps(user_dict)
+
+# should not catch the following
+def dumps():
+  pass
+def test_empty_dumps():
+    dumps()
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed
new file mode 100644
index 000000000000..ae7d1a9a2a66
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/autofix/flask-use-jsonify.py-fixed
@@ -0,0 +1,24 @@
+## Normal import
+import flask
+import json
+app = flask.Flask(__name__)
+
+@app.route("/user")
+def user():
+    user_dict = get_user(request.args.get("id"))
+    # ruleid:use-jsonify
+    return flask.jsonify(user_dict)
+
+from json import dumps
+
+@app.route("/user")
+def user():
+    user_dict = get_user(request.args.get("id"))
+    # ruleid:use-jsonify
+    return flask.jsonify(user_dict)
+
+# should not catch the following
+def dumps():
+  pass
+def test_empty_dumps():
+    dumps()
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/flask-use-jsonify.yaml-flask-use-jsonify.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun
new file mode 100644
index 000000000000..09fac856a566
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/autofix/imported-entity.py-dryrun
@@ -0,0 +1,4 @@
+from randomlib.errors import RandomError
+
+def test_test():
+  raise RandomError()
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed
new file mode 100644
index 000000000000..2c456c28bc2f
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/autofix/imported-entity.py-fixed
@@ -0,0 +1,4 @@
+from randomlib.errors import RandomError
+
+def test_test():
+  raise RandomError
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/imported-entity.yaml-imported-entity.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/imported-entity.yaml-imported-entity.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun
new file mode 100644
index 000000000000..b407a079f175
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/autofix/java-string-wrap.java-dryrun
@@ -0,0 +1,5 @@
+public class C {
+    String f() {
+        return "a" + "b";
+    }
+}
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed
new file mode 100644
index 000000000000..a37b7d99ffa7
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/autofix/java-string-wrap.java-fixed
@@ -0,0 +1,5 @@
+public class C {
+    String f() {
+        return wrap("a") + wrap("b");
+    }
+}
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/java-string-wrap.yaml-java-string-wrap.java-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun
new file mode 100644
index 000000000000..52e15626cc06
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/autofix/ocaml_paren_expr.ml-dryrun
@@ -0,0 +1,7 @@
+let one = a_function_call the_argument
+
+let two = a_function_call (the_argument)
+
+let three = a_function_call (another_func the_argument)
+
+let three = a_function_call (tuple_member, threeple_member)
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
new file mode 100644
index 000000000000..b9f366333360
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/autofix/ocaml_paren_expr.ml-fixed
@@ -0,0 +1,7 @@
+let one = a_function_call (wrap (the_argument))
+
+let two = a_function_call (wrap (the_argument))
+
+let three = a_function_call (wrap ((another_func the_argument)))
+
+let three = a_function_call (wrap ((tuple_member, threeple_member)))
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/ocaml_paren_expr.yaml-ocaml_paren_expr.ml-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun
new file mode 100644
index 000000000000..dd2b74360187
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/autofix/order.py-dryrun
@@ -0,0 +1,12 @@
+# Several findings will overlap in the following array. The autofixes
+# must be applied in the order in which the findings are discovered or
+# in which they are reported (which is it?).
+
+a
+b
+c
+d
+e
+f
+g
+h
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed
new file mode 100644
index 000000000000..27de1b1b1176
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/autofix/order.py-fixed
@@ -0,0 +1,8 @@
+# Several findings will overlap in the following array. The autofixes
+# must be applied in the order in which the findings are discovered or
+# in which they are reported (which is it?).
+
+replaced_lines_a_to_b_OK
+c
+replaced_lines_d_to_f_OK
+replaced_lines_g_to_h_OK
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/order.yaml-order.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/order.yaml-order.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
new file mode 100644
index 000000000000..996d098d84f6
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/autofix/collision.py-dryrun
@@ -0,0 +1,5 @@
+
+a = 1
+b = 2
+c = 3
+d = 4
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
new file mode 100644
index 000000000000..44ef3f5d35bc
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/autofix/collision.py-fixed
@@ -0,0 +1,3 @@
+
+once
+d = 4
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/overlapping-collision.yaml-collision.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/overlapping-collision.yaml-collision.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun
new file mode 100644
index 000000000000..57f2a6d02e37
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/autofix/python-assert-statement.py-dryrun
@@ -0,0 +1,7 @@
+assert_eq(
+    True, "a"
+)
+assert_eq(
+    True, "b"
+)
+x = "abc"
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed
new file mode 100644
index 000000000000..2497d93d327c
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/autofix/python-assert-statement.py-fixed
@@ -0,0 +1,3 @@
+assert "a"
+assert "b"
+x = "abc"
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-assert-statement.yaml-python-assert-statement.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun
new file mode 100644
index 000000000000..c68e5f8b43f2
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/autofix/python-delete-import.py-dryrun
@@ -0,0 +1,8 @@
+import sys
+from typing import Dict
+from typing import List
+
+def hello():
+    print("hello world")
+
+hello()
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed
new file mode 100644
index 000000000000..709d00360005
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/autofix/python-delete-import.py-fixed
@@ -0,0 +1,6 @@
+import sys
+
+def hello():
+    print("hello world")
+
+hello()
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-delete-import.yaml-python-delete-import.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun
new file mode 100644
index 000000000000..75123471a68d
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/autofix/python-ranges.py-dryrun
@@ -0,0 +1,9 @@
+foo('abc')
+foo("abc")
+foo("""abc""")
+foo(r"abc")
+foo(f"abc")
+foo([1])
+foo([1, 2])
+foo(x[1])
+foo(x[1, 2])
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed
new file mode 100644
index 000000000000..4236ea521028
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/autofix/python-ranges.py-fixed
@@ -0,0 +1,9 @@
+bar('abc')
+bar("abc")
+bar("""abc""")
+bar(r"abc")
+bar(f"abc")
+bar([1])
+bar([1, 2])
+bar(x[1])
+bar(x[1, 2])
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/python-ranges.yaml-python-ranges.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/python-ranges.yaml-python-ranges.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun
new file mode 100644
index 000000000000..71ac1b579120
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/autofix/redundant.py-dryrun
@@ -0,0 +1,8 @@
+a
+b
+c
+d
+e
+f
+g
+h
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed
new file mode 100644
index 000000000000..56a3ac7e11fe
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/autofix/redundant.py-fixed
@@ -0,0 +1,6 @@
+replaced_lines_a_to_c_OK
+d
+e
+f
+g
+h
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/redundant.yaml-redundant.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/redundant.yaml-redundant.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun
new file mode 100644
index 000000000000..cd48e828d1a7
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/autofix/replace-field-yaml.yaml-dryrun
@@ -0,0 +1,6 @@
+x:
+  # ruleid: yaml-excessive-capture
+  y:
+    foo: true
+  # comment
+  z: "z"
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed
new file mode 100644
index 000000000000..4264d149431a
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/autofix/replace-field-yaml.yaml-fixed
@@ -0,0 +1,5 @@
+x:
+  # ruleid: yaml-excessive-capture
+  y: false
+  # comment
+  z: "z"
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/replace-field-yaml.yaml-replace-field-yaml.yaml-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun
new file mode 100644
index 000000000000..825ee8071050
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/autofix/requests-use-timeout.py-dryrun
@@ -0,0 +1,38 @@
+import requests
+
+url = "www.github.com"
+
+# ruleid: use-timeout
+r = requests.get(url)
+
+# ruleid: use-timeout
+r = requests.post(url)
+
+# ruleid: use-timeout
+r = requests.request("GET", url)
+
+def return_url():
+    return url
+
+# ruleid: use-timeout
+r = requests.request("GET", return_url())
+
+# ok
+r = requests.get(url, timeout=50)
+
+def from_import_test1(url):
+    from requests import get, post
+    # ok
+    r = get(url, timeout=3)
+
+    # ruleid: use-timeout
+    r = post(url)
+
+def test2():
+    """Perform a requests.get and default headers set"""
+    headers = {**_get_default_headers(), **headers}
+    # ok
+    r = requests.get(
+        url, headers=headers, params=params, **{"timeout": TIMEOUT, **kwargs}
+    )
+    return r
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed
new file mode 100644
index 000000000000..24cd35f5adf4
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/autofix/requests-use-timeout.py-fixed
@@ -0,0 +1,38 @@
+import requests
+
+url = "www.github.com"
+
+# ruleid: use-timeout
+r = requests.get(url, timeout=30)
+
+# ruleid: use-timeout
+r = requests.post(url, timeout=30)
+
+# ruleid: use-timeout
+r = requests.request("GET", url, timeout=30)
+
+def return_url():
+    return url
+
+# ruleid: use-timeout
+r = requests.request("GET", return_url(), timeout=30)
+
+# ok
+r = requests.get(url, timeout=50)
+
+def from_import_test1(url):
+    from requests import get, post
+    # ok
+    r = get(url, timeout=3)
+
+    # ruleid: use-timeout
+    r = post(url, timeout=30)
+
+def test2():
+    """Perform a requests.get and default headers set"""
+    headers = {**_get_default_headers(), **headers}
+    # ok
+    r = requests.get(
+        url, headers=headers, params=params, **{"timeout": TIMEOUT, **kwargs}
+    )
+    return r
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/requests-use-timeout.yaml-requests-use-timeout.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun
new file mode 100644
index 000000000000..229bab1dadd6
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/autofix/three-autofixes.py-dryrun
@@ -0,0 +1,24 @@
+import requests
+
+def _do_stuff():
+    return requests.post(
+        headers=auth_headers,
+        url=self.url,
+        endpoints="endpoint",
+    )
+
+
+def _do_other_stuff():
+    return requests.post(
+        headers=auth_headers,
+        url=self.url,
+        endpoints="endpoint",
+    )
+
+
+def _do_other_stuff2():
+    return requests.post(
+        headers=auth_headers,
+        url=self.url,
+        endpoints="endpoint"
+    )
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed
new file mode 100644
index 000000000000..750210791415
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/autofix/three-autofixes.py-fixed
@@ -0,0 +1,12 @@
+import requests
+
+def _do_stuff():
+    return requests.post(headers=auth_headers, url=self.url, endpoints="endpoint", _injected=True)
+
+
+def _do_other_stuff():
+    return requests.post(headers=auth_headers, url=self.url, endpoints="endpoint", _injected=True)
+
+
+def _do_other_stuff2():
+    return requests.post(headers=auth_headers, url=self.url, endpoints="endpoint", _injected=True)
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/three-autofixes.yaml-three-autofixes.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun
new file mode 100644
index 000000000000..f384549cbeb4
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/autofix/two-autofixes.txt-dryrun
@@ -0,0 +1,4 @@
+one
+two
+three
+four
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed
new file mode 100644
index 000000000000..a9c769858df1
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/autofix/two-autofixes.txt-fixed
@@ -0,0 +1,2 @@
+one
+four
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/two-autofixes.yaml-two-autofixes.txt-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-json-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-json-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-json-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-json-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-json-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun
new file mode 100644
index 000000000000..507a0965d791
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/autofix/utf-8.py-dryrun
@@ -0,0 +1,16 @@
+# Tests autofixes when non-ascii characters are present in the source file and
+# the fix.
+#
+# In Python, strings are sequences of characters. In OCaml, they are sequences
+# of bytes. Because of this, we need to be particularly careful that we do not
+# conflate bytes with characters.
+
+# U+1F60A. Has a 4 byte encoding in utf-8. This is here to throw off offsets
+# later in the file if we are computing them incorrectly.
+x = '😊'
+
+print('a')
+
+# And this one is here at the beginning of a line to throw off columns later in
+# the line if we are computing them incorrectly.
+y = '😊'; print('a'); print('a')
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed
new file mode 100644
index 000000000000..1136510166d5
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/autofix/utf-8.py-fixed
@@ -0,0 +1,16 @@
+# Tests autofixes when non-ascii characters are present in the source file and
+# the fix.
+#
+# In Python, strings are sequences of characters. In OCaml, they are sequences
+# of bytes. Because of this, we need to be particularly careful that we do not
+# conflate bytes with characters.
+
+# U+1F60A. Has a 4 byte encoding in utf-8. This is here to throw off offsets
+# later in the file if we are computing them incorrectly.
+x = '😊'
+
+print('😊')
+
+# And this one is here at the beginning of a line to throw off columns later in
+# the line if we are computing them incorrectly.
+y = '😊'; print('😊'); print('😊')
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-not-dryrun/results.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/results.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-not-dryrun/results.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/results.txt
diff --git a/cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-not-dryrun/stderr.txt b/cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/stderr.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_autofix/test/utf-8.yaml-utf-8.py-text-not-dryrun/stderr.txt
rename to cli/tests/default/e2e/snapshots/test_autofix/test_autofix_text_output/utf-8.yaml-utf-8.py-text-not-dryrun/stderr.txt
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude-and-include/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude-and-include/files.list
index 23cae47b9d35..57fd6b103f90 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude-and-include/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude-and-include/files.list
@@ -1,2 +1,2 @@
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude0/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude0/files.list
index fb61905f7d60..95b4756716e1 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude0/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude0/files.list
@@ -1,3 +1,3 @@
-selected targets/exclude_include/excluded/included.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/excluded/included.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude1/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude1/files.list
index 33b42cca4d6c..42f8a83eb8eb 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude1/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-exclude1/files.list
@@ -1,3 +1,3 @@
-selected targets/exclude_include/included/excluded.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/included/excluded.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-exclude/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-exclude/files.list
index 23cae47b9d35..57fd6b103f90 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-exclude/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-exclude/files.list
@@ -1,2 +1,2 @@
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include0/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include0/files.list
index 43f07caef878..c98cf0a5639c 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include0/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include0/files.list
@@ -1,5 +1,5 @@
-selected targets/exclude_include/excluded/excluded.js
-selected targets/exclude_include/excluded/included.js
-selected targets/exclude_include/included/excluded.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/excluded/excluded.js
+targets/exclude_include/excluded/included.js
+targets/exclude_include/included/excluded.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include1/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include1/files.list
index 43f07caef878..c98cf0a5639c 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include1/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-include1/files.list
@@ -1,5 +1,5 @@
-selected targets/exclude_include/excluded/excluded.js
-selected targets/exclude_include/excluded/included.js
-selected targets/exclude_include/included/excluded.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/excluded/excluded.js
+targets/exclude_include/excluded/included.js
+targets/exclude_include/included/excluded.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-skip-unknown-extensions/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-skip-unknown-extensions/files.list
index a5bca99d5fde..00bc713aaae3 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-skip-unknown-extensions/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include-and-skip-unknown-extensions/files.list
@@ -1,1 +1,1 @@
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include0/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include0/files.list
index fb61905f7d60..95b4756716e1 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include0/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include0/files.list
@@ -1,3 +1,3 @@
-selected targets/exclude_include/excluded/included.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/excluded/included.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include1/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include1/files.list
index 33b42cca4d6c..42f8a83eb8eb 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include1/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include1/files.list
@@ -1,3 +1,3 @@
-selected targets/exclude_include/included/excluded.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/included/excluded.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include2/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include2/files.list
index a5bca99d5fde..00bc713aaae3 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include2/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include2/files.list
@@ -1,1 +1,1 @@
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include3/files.list b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include3/files.list
index 43f07caef878..c98cf0a5639c 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include3/files.list
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include/test_exclude_include/x-ls-and-include3/files.list
@@ -1,5 +1,5 @@
-selected targets/exclude_include/excluded/excluded.js
-selected targets/exclude_include/excluded/included.js
-selected targets/exclude_include/included/excluded.js
-selected targets/exclude_include/included/included.js
-selected targets/exclude_include/included/included.vue
+targets/exclude_include/excluded/excluded.js
+targets/exclude_include/excluded/included.js
+targets/exclude_include/included/excluded.js
+targets/exclude_include/included/included.js
+targets/exclude_include/included/included.vue
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_1/results.err b/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_1/results.err
index 0b04b666532b..ec2231789840 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_1/results.err
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_1/results.err
@@ -42,6 +42,10 @@ Files skipped:
    • targets/exclude_include/included/excluded.js
    • targets/exclude_include/included/included.js
 
+  Files skipped due to insufficient read permissions:
+
+   • <none>
+
   Skipped by limiting to files smaller than 1000000 bytes:
   (Adjust with the --max-target-bytes flag)
 
diff --git a/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_2/results.err b/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_2/results.err
index c35f58710ce8..5068ba274ee6 100644
--- a/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_2/results.err
+++ b/cli/tests/default/e2e/snapshots/test_exclude_include_formatting/test_exclude_include_verbose_sorted_2/results.err
@@ -52,6 +52,10 @@ Files skipped:
    • targets/basic/stupid.js
    • targets/basic/stupid.py
 
+  Files skipped due to insufficient read permissions:
+
+   • <none>
+
   Skipped by limiting to files smaller than 1000000 bytes:
   (Adjust with the --max-target-bytes flag)
 
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1B/results.json b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1B/results.json
deleted file mode 100644
index 65dc4effcf81..000000000000
--- a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1B/results.json
+++ /dev/null
@@ -1,9 +0,0 @@
-{
-  "errors": [],
-  "paths": {
-    "scanned": []
-  },
-  "results": [],
-  "skipped_rules": [],
-  "version": "0.42"
-}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1.3R/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output/1.3R/error.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1.3R/error.txt
rename to cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output/1.3R/error.txt
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1MB/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output/1MB/error.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1MB/error.txt
rename to cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output/1MB/error.txt
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output_pysemfail/100B/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output_pysemfail/100B/error.txt
new file mode 100644
index 000000000000..51d3273cddea
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output_pysemfail/100B/error.txt
@@ -0,0 +1,23 @@
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 10 files tracked by git with 4 Code rules:
+
+  Language   Rules   Files          Origin   Rules
+ ──────────────────────────        ────────────────
+  python         3       6          Custom       4
+  js             1       1
+
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+Some files were skipped or only partially analyzed.
+  Scan was limited to files tracked by git.  Scan skipped: 14 files larger than 0 MB.
+  For a full list of skipped files, run semgrep with the --verbose flag.
+
+
+Ran 4 rules on 7 files: 1 finding.
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output_pysemfail/1B/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output_pysemfail/1B/error.txt
new file mode 100644
index 000000000000..02c824031361
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_output_pysemfail/1B/error.txt
@@ -0,0 +1,21 @@
+
+
+┌─────────────┐
+│ Scan Status │
+└─────────────┘
+  Scanning 0 files tracked by git with 4 Code rules:
+
+  Language   Rules   Files          Origin   Rules
+ ──────────────────────────        ────────────────
+
+
+
+┌──────────────┐
+│ Scan Summary │
+└──────────────┘
+Some files were skipped or only partially analyzed.
+  Scan was limited to files tracked by git.  Scan skipped: 24 files larger than 0 MB.
+  For a full list of skipped files, run semgrep with the --verbose flag.
+
+
+Ran 4 rules on 0 files: 0 findings.
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1.3R/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1.3R/error.txt
new file mode 100644
index 000000000000..87c8c7d8c490
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1.3R/error.txt
@@ -0,0 +1,4 @@
+semgrep scan: option '--max-target-bytes': Invalid representation for a
+              number of bytes: 1.3R
+Usage: semgrep scan [OPTION]… [TARGETS]…
+Try 'semgrep scan --help' for more information.
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1.3R/results.json b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1.3R/results.json
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/100B/results.json b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/100B/results.json
new file mode 100644
index 000000000000..6c676b6c22ea
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/100B/results.json
@@ -0,0 +1,71 @@
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/basic/metavariable-comparison-bad-content.py",
+      "targets/basic/metavariable-comparison-base.py",
+      "targets/basic/metavariable-comparison-strip.py",
+      "targets/basic/metavariable-regex-multi-rule.py",
+      "targets/basic/metavariable-regex.py",
+      "targets/basic/regex.py",
+      "targets/basic/stupid.js"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.javascript-basic-eqeq-bad",
+      "end": {
+        "col": 19,
+        "line": 3,
+        "offset": 67
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "console.log(x == x)",
+        "message": "useless comparison",
+        "metadata": {},
+        "metavars": {
+          "$X": {
+            "abstract_content": "x",
+            "end": {
+              "col": 14,
+              "line": 3,
+              "offset": 62
+            },
+            "propagated_value": {
+              "svalue_abstract_content": "window.prompt()",
+              "svalue_end": {
+                "col": 24,
+                "line": 1,
+                "offset": 23
+              },
+              "svalue_start": {
+                "col": 9,
+                "line": 1,
+                "offset": 8
+              }
+            },
+            "start": {
+              "col": 13,
+              "line": 3,
+              "offset": 61
+            }
+          }
+        },
+        "severity": "ERROR",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/basic/stupid.js",
+      "start": {
+        "col": 13,
+        "line": 3,
+        "offset": 61
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1B/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1B/error.txt
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1B/error.txt
rename to cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1B/error.txt
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_osemfail/1B/results.json b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1B/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_osemfail/1B/results.json
rename to cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1B/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_osemfail/1B/error.txt b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1MB/error.txt
similarity index 55%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_osemfail/1B/error.txt
rename to cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1MB/error.txt
index 4f51ddfecb19..5fafb11caea3 100644
--- a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_osemfail/1B/error.txt
+++ b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1MB/error.txt
@@ -4,7 +4,12 @@
 │ Scan Status │
 └─────────────┘
   Scanning 24 files tracked by git with 4 Code rules:
-  Nothing to scan.
+
+  Language   Rules   Files          Origin   Rules
+ ──────────────────────────        ────────────────
+  python         3      11          Custom       4
+  js             1       3
+
 
 
 ┌──────────────┐
@@ -12,7 +17,5 @@
 └──────────────┘
 Some files were skipped or only partially analyzed.
   Scan was limited to files tracked by git.
-  Scan skipped: 14 files larger than 1e-06 MB
-  For a full list of skipped files, run semgrep with the --verbose flag.
 
-Ran 4 rules on 0 files: 0 findings.
+Ran 4 rules on 14 files: 2 findings.
diff --git a/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1MB/results.json b/cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1MB/results.json
similarity index 100%
rename from cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes/1MB/results.json
rename to cli/tests/default/e2e/snapshots/test_max_target_bytes/test_max_target_bytes_results/1MB/results.json
diff --git a/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_ls/files.list b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_ls/files.list
new file mode 100644
index 000000000000..6480c3507d1b
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_ls/files.list
@@ -0,0 +1,2 @@
+targets/permissions/README
+targets/permissions/readable_file.py
diff --git a/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan/results.json b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan/results.json
new file mode 100644
index 000000000000..299d82453417
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan/results.json
@@ -0,0 +1,65 @@
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/permissions/readable_file.py"
+    ],
+    "skipped": [
+      {
+        "path": "targets/permissions/unreadable_file.py",
+        "reason": "insufficient_permissions"
+      },
+      {
+        "path": "targets/permissions/unreadable_subdir",
+        "reason": "insufficient_permissions"
+      }
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.eqeq-is-bad",
+      "end": {
+        "col": 7,
+        "line": 1,
+        "offset": 6
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "a == a",
+        "message": "useless comparison operation `a == a` or `a != a`",
+        "metadata": {
+          "shortlink": "https://sg.run/xyz1",
+          "source": "https://semgrep.dev/r/eqeq-bad"
+        },
+        "metavars": {
+          "$X": {
+            "abstract_content": "a",
+            "end": {
+              "col": 2,
+              "line": 1,
+              "offset": 1
+            },
+            "start": {
+              "col": 1,
+              "line": 1,
+              "offset": 0
+            }
+          }
+        },
+        "severity": "ERROR",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/permissions/readable_file.py",
+      "start": {
+        "col": 1,
+        "line": 1,
+        "offset": 0
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan_full_lax/results.json b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan_full_lax/results.json
new file mode 100644
index 000000000000..42d1842da9f6
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan_full_lax/results.json
@@ -0,0 +1,55 @@
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/permissions/readable_file.py"
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.eqeq-is-bad",
+      "end": {
+        "col": 7,
+        "line": 1,
+        "offset": 6
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "a == a",
+        "message": "useless comparison operation `a == a` or `a != a`",
+        "metadata": {
+          "shortlink": "https://sg.run/xyz1",
+          "source": "https://semgrep.dev/r/eqeq-bad"
+        },
+        "metavars": {
+          "$X": {
+            "abstract_content": "a",
+            "end": {
+              "col": 2,
+              "line": 1,
+              "offset": 1
+            },
+            "start": {
+              "col": 1,
+              "line": 1,
+              "offset": 0
+            }
+          }
+        },
+        "severity": "ERROR",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/permissions/readable_file.py",
+      "start": {
+        "col": 1,
+        "line": 1,
+        "offset": 0
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan_full_strict/results.json b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan_full_strict/results.json
new file mode 100644
index 000000000000..299d82453417
--- /dev/null
+++ b/cli/tests/default/e2e/snapshots/test_permissions/test_permissions_scan_full_strict/results.json
@@ -0,0 +1,65 @@
+{
+  "errors": [],
+  "interfile_languages_used": [],
+  "paths": {
+    "scanned": [
+      "targets/permissions/readable_file.py"
+    ],
+    "skipped": [
+      {
+        "path": "targets/permissions/unreadable_file.py",
+        "reason": "insufficient_permissions"
+      },
+      {
+        "path": "targets/permissions/unreadable_subdir",
+        "reason": "insufficient_permissions"
+      }
+    ]
+  },
+  "results": [
+    {
+      "check_id": "rules.eqeq-is-bad",
+      "end": {
+        "col": 7,
+        "line": 1,
+        "offset": 6
+      },
+      "extra": {
+        "engine_kind": "OSS",
+        "fingerprint": "0x42",
+        "is_ignored": false,
+        "lines": "a == a",
+        "message": "useless comparison operation `a == a` or `a != a`",
+        "metadata": {
+          "shortlink": "https://sg.run/xyz1",
+          "source": "https://semgrep.dev/r/eqeq-bad"
+        },
+        "metavars": {
+          "$X": {
+            "abstract_content": "a",
+            "end": {
+              "col": 2,
+              "line": 1,
+              "offset": 1
+            },
+            "start": {
+              "col": 1,
+              "line": 1,
+              "offset": 0
+            }
+          }
+        },
+        "severity": "ERROR",
+        "validation_state": "NO_VALIDATOR"
+      },
+      "path": "targets/permissions/readable_file.py",
+      "start": {
+        "col": 1,
+        "line": 1,
+        "offset": 0
+      }
+    }
+  ],
+  "skipped_rules": [],
+  "version": "0.42"
+}
\ No newline at end of file
diff --git a/cli/tests/default/e2e/snapshots/test_process_limits/test_max_memory/error.txt b/cli/tests/default/e2e/snapshots/test_process_limits/test_max_memory/error.txt
index fe4b36f36706..86c20239d229 100644
--- a/cli/tests/default/e2e/snapshots/test_process_limits/test_max_memory/error.txt
+++ b/cli/tests/default/e2e/snapshots/test_process_limits/test_max_memory/error.txt
@@ -36,6 +36,10 @@ Files skipped:
 
    • <none>
 
+  Files skipped due to insufficient read permissions:
+
+   • <none>
+
   Skipped by limiting to files smaller than 1000000 bytes:
   (Adjust with the --max-target-bytes flag)
 
diff --git a/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error.txt b/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error.txt
index 96b44088b625..26c78e7e12fc 100644
--- a/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error.txt
+++ b/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error.txt
@@ -42,6 +42,10 @@ Files skipped:
 
    • <none>
 
+  [1m[24mFiles skipped due to insufficient read permissions:[0m
+
+   • <none>
+
   [1m[24mSkipped by limiting to files smaller than 1000000 bytes:[0m
   [1m[24m(Adjust with the --max-target-bytes flag)[0m
 
diff --git a/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error_2.txt b/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error_2.txt
index c14689900ba7..547275c3a59c 100644
--- a/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error_2.txt
+++ b/cli/tests/default/e2e/snapshots/test_process_limits/test_timeout_threshold/error_2.txt
@@ -42,6 +42,10 @@ Files skipped:
 
    • <none>
 
+  [1m[24mFiles skipped due to insufficient read permissions:[0m
+
+   • <none>
+
   [1m[24mSkipped by limiting to files smaller than 1000000 bytes:[0m
   [1m[24m(Adjust with the --max-target-bytes flag)[0m
 
diff --git a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings0/error.txt b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings0/error.txt
index a0901b0a9098..8dd86829b719 100644
--- a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings0/error.txt
+++ b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings0/error.txt
@@ -36,6 +36,10 @@ Files skipped:
 
    • <none>
 
+  [1m[24mFiles skipped due to insufficient read permissions:[0m
+
+   • <none>
+
   [1m[24mSkipped by limiting to files smaller than 1000000 bytes:[0m
   [1m[24m(Adjust with the --max-target-bytes flag)[0m
 
diff --git a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings1/error.txt b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings1/error.txt
index e4c71791daec..7e1801b292fd 100644
--- a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings1/error.txt
+++ b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_file_parser__failure__error_messages/settings1/error.txt
@@ -37,6 +37,10 @@ Files skipped:
 
    • <none>
 
+  [1m[24mFiles skipped due to insufficient read permissions:[0m
+
+   • <none>
+
   [1m[24mSkipped by limiting to files smaller than 1000000 bytes:[0m
   [1m[24m(Adjust with the --max-target-bytes flag)[0m
 
diff --git a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_parse_errors/errors.txt b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_parse_errors/errors.txt
index 4fc4fcf4182a..a28d20074e30 100644
--- a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_parse_errors/errors.txt
+++ b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_parse_errors/errors.txt
@@ -39,6 +39,10 @@ Files skipped:
 
    • <none>
 
+  [1m[24mFiles skipped due to insufficient read permissions:[0m
+
+   • <none>
+
   [1m[24mSkipped by limiting to files smaller than 1000000 bytes:[0m
   [1m[24m(Adjust with the --max-target-bytes flag)[0m
 
diff --git a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_rule_parser__failure__error_messages/settings0/error.txt b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_rule_parser__failure__error_messages/settings0/error.txt
index 3e12044ff066..6e2ee6763c4f 100644
--- a/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_rule_parser__failure__error_messages/settings0/error.txt
+++ b/cli/tests/default/e2e/snapshots/test_semgrep_core_parse_error/test_rule_parser__failure__error_messages/settings0/error.txt
@@ -36,6 +36,10 @@ Files skipped:
 
    • <none>
 
+  [1m[24mFiles skipped due to insufficient read permissions:[0m
+
+   • <none>
+
   [1m[24mSkipped by limiting to files smaller than 1000000 bytes:[0m
   [1m[24m(Adjust with the --max-target-bytes flag)[0m
 
diff --git a/cli/tests/default/e2e/targets/permissions/README b/cli/tests/default/e2e/targets/permissions/README
new file mode 100644
index 000000000000..700f665a7fe6
--- /dev/null
+++ b/cli/tests/default/e2e/targets/permissions/README
@@ -0,0 +1,2 @@
+Target files for test_permissions are added dynamically since it requires
+special permissions on files and folders
diff --git a/cli/tests/default/e2e/test_autofix.py b/cli/tests/default/e2e/test_autofix.py
index a1ffa8fad205..b0c85cc4f310 100644
--- a/cli/tests/default/e2e/test_autofix.py
+++ b/cli/tests/default/e2e/test_autofix.py
@@ -4,43 +4,10 @@
 from semgrep.constants import OutputFormat
 
 
-@pytest.mark.osemfail
-@pytest.mark.kinda_slow
-@pytest.mark.parametrize("dryrun", [True, False], ids=["dryrun", "not-dryrun"])
-@pytest.mark.parametrize(
-    "output_format", [OutputFormat.JSON, OutputFormat.TEXT], ids=["json", "text"]
-)
-# The rule is looked in rules/autofix/ and the target in autofix/ but
-# we use shorter names below so the snapshot dirs use less characters
-# which can be an issue on Windows.
-@pytest.mark.parametrize(
-    "rule,target",
-    [
-        ("autofix.yaml", "autofix.py"),
-        ("overlapping-collision.yaml", "collision.py"),
-        ("python-assert-statement.yaml", "python-assert-statement.py"),
-        ("python-ranges.yaml", "python-ranges.py"),
-        ("replace-field-yaml.yaml", "replace-field-yaml.yaml"),
-        ("three-autofixes.yaml", "three-autofixes.py"),
-        ("java-string-wrap.yaml", "java-string-wrap.java"),
-        ("exact-collision.yaml", "collision.py"),
-        ("order.yaml", "order.py"),
-        ("redundant.yaml", "redundant.py"),
-        ("ocaml_paren_expr.yaml", "ocaml_paren_expr.ml"),
-        ("python-delete-import.yaml", "python-delete-import.py"),
-        ("two-autofixes.yaml", "two-autofixes.txt"),
-        ("csv-writer.yaml", "csv-writer.py"),
-        ("defaulthttpclient.yaml", "defaulthttpclient.java"),
-        ("flask-use-jsonify.yaml", "flask-use-jsonify.py"),
-        ("requests-use-timeout.yaml", "requests-use-timeout.py"),
-        ("django-none-password-default.yaml", "django-none-password-default.py"),
-        ("imported-entity.yaml", "imported-entity.py"),
-        ("add-metadata-hcl.yaml", "add-metadata-hcl.hcl"),
-        ("delete-partial-line.yaml", "delete-partial-line.py"),
-        ("utf-8.yaml", "utf-8.py"),
-    ],
-)
-def test(
+# Depending on the parameters, some tests pass with osemgrep or don't
+# The actual test functions are split accordingly and call 'run_test_autofix'.
+#
+def run_test_autofix(
     run_semgrep_on_copied_files: RunSemgrep,
     tmp_path,
     snapshot,
@@ -68,10 +35,12 @@ def test(
         results_file,
     )
 
-    snapshot.assert_match(
-        semgrep_result.stderr,
-        "stderr.txt",
-    )
+    if output_format == OutputFormat.TEXT:
+        # TODO: is this essential?
+        snapshot.assert_match(
+            semgrep_result.stderr,
+            "stderr.txt",
+        )
 
     # Now make sure the files are actually updated
     with open(tmp_path / "targets" / target) as fd:
@@ -80,3 +49,119 @@ def test(
         result,
         (f"{target}-dryrun" if dryrun else f"{target}-fixed"),
     )
+
+
+# The rule is looked up in rules/autofix/ and the target in autofix/ but
+# we use shorter names below so the snapshot dirs use less characters
+# which can be an issue on Windows.
+RULE_TARGET_PAIRS_JSON_OSEMPASS = [
+    ("autofix.yaml", "autofix.py"),
+    ("overlapping-collision.yaml", "collision.py"),
+    ("python-assert-statement.yaml", "python-assert-statement.py"),
+    ("python-ranges.yaml", "python-ranges.py"),
+    ("replace-field-yaml.yaml", "replace-field-yaml.yaml"),
+    ("three-autofixes.yaml", "three-autofixes.py"),
+    ("java-string-wrap.yaml", "java-string-wrap.java"),
+    ("exact-collision.yaml", "collision.py"),
+    ("redundant.yaml", "redundant.py"),
+    ("ocaml_paren_expr.yaml", "ocaml_paren_expr.ml"),
+    ("python-delete-import.yaml", "python-delete-import.py"),
+    ("two-autofixes.yaml", "two-autofixes.txt"),
+    ("csv-writer.yaml", "csv-writer.py"),
+    ("defaulthttpclient.yaml", "defaulthttpclient.java"),
+    ("flask-use-jsonify.yaml", "flask-use-jsonify.py"),
+    ("requests-use-timeout.yaml", "requests-use-timeout.py"),
+    ("django-none-password-default.yaml", "django-none-password-default.py"),
+    ("imported-entity.yaml", "imported-entity.py"),
+    ("add-metadata-hcl.yaml", "add-metadata-hcl.hcl"),
+    ("delete-partial-line.yaml", "delete-partial-line.py"),
+    ("utf-8.yaml", "utf-8.py"),
+]
+
+# rule/target pairs for which osemgrep fails to produce the same JSON output
+# as pysemgrep.
+RULE_TARGET_PAIRS_JSON_OSEMFAIL = [
+    # TODO: gives slightly different JSON output with osemgrep
+    ("order.yaml", "order.py"),
+]
+
+# all rule/target pairs
+RULE_TARGET_PAIRS = RULE_TARGET_PAIRS_JSON_OSEMPASS + RULE_TARGET_PAIRS_JSON_OSEMFAIL
+
+
+# Check only the JSON output i.e. the correctness of the results rather
+# than formatting.
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize("dryrun", [True, False], ids=["dryrun", "not-dryrun"])
+@pytest.mark.parametrize("output_format", [OutputFormat.JSON], ids=["json"])
+@pytest.mark.parametrize("rule,target", RULE_TARGET_PAIRS_JSON_OSEMPASS)
+def test_autofix_json_output(
+    run_semgrep_on_copied_files: RunSemgrep,
+    tmp_path,
+    snapshot,
+    rule,
+    target,
+    dryrun,
+    output_format,
+):
+    run_test_autofix(
+        run_semgrep_on_copied_files,
+        tmp_path,
+        snapshot,
+        rule,
+        target,
+        dryrun,
+        output_format,
+    )
+
+
+# Same as above but gives different results between pysemgrep and osemgrep
+@pytest.mark.osemfail
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize("dryrun", [True, False], ids=["dryrun", "not-dryrun"])
+@pytest.mark.parametrize("output_format", [OutputFormat.JSON], ids=["json"])
+@pytest.mark.parametrize("rule,target", RULE_TARGET_PAIRS_JSON_OSEMFAIL)
+def test_autofix_json_output_osemfail(
+    run_semgrep_on_copied_files: RunSemgrep,
+    tmp_path,
+    snapshot,
+    rule,
+    target,
+    dryrun,
+    output_format,
+):
+    run_test_autofix(
+        run_semgrep_on_copied_files,
+        tmp_path,
+        snapshot,
+        rule,
+        target,
+        dryrun,
+        output_format,
+    )
+
+
+# Check only text output i.e. correctness of the findings + formatting
+@pytest.mark.osemfail
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize("dryrun", [True, False], ids=["dryrun", "not-dryrun"])
+@pytest.mark.parametrize("output_format", [OutputFormat.TEXT], ids=["text"])
+@pytest.mark.parametrize("rule,target", RULE_TARGET_PAIRS)
+def test_autofix_text_output(
+    run_semgrep_on_copied_files: RunSemgrep,
+    tmp_path,
+    snapshot,
+    rule,
+    target,
+    dryrun,
+    output_format,
+):
+    run_test_autofix(
+        run_semgrep_on_copied_files,
+        tmp_path,
+        snapshot,
+        rule,
+        target,
+        dryrun,
+        output_format,
+    )
diff --git a/cli/tests/default/e2e/test_baseline.py b/cli/tests/default/e2e/test_baseline.py
index fc6eb7ca35dd..f954672ded57 100644
--- a/cli/tests/default/e2e/test_baseline.py
+++ b/cli/tests/default/e2e/test_baseline.py
@@ -715,7 +715,6 @@ def test_commit_doesnt_exist(git_tmp_path, snapshot):
     assert_err_match(snapshot, output, "error.txt")
 
 
-@pytest.mark.osemfail
 @pytest.fixture
 def complex_merge_repo(git_tmp_path, snapshot):
     r"""
diff --git a/cli/tests/default/e2e/test_exclude_include.py b/cli/tests/default/e2e/test_exclude_include.py
index 8d99e749976b..9a573cfe95f5 100644
--- a/cli/tests/default/e2e/test_exclude_include.py
+++ b/cli/tests/default/e2e/test_exclude_include.py
@@ -1,5 +1,3 @@
-import re
-
 import pytest
 from tests.fixtures import RunSemgrep
 
@@ -8,18 +6,6 @@ def idfn(options):
     return "-and-".join(flag.strip("-") for flag in options if flag.startswith("--"))
 
 
-IGNORED_TARGET_LINE = re.compile("ignored .*\n")
-
-
-# We mask the lines such as the following in the output of --x-ls:
-# ignored targets/exclude_include/excluded/excluded.js [cli_exclude_flags_match]
-# because it's only a debug help and osemgrep and pysemgrep show different
-# things.
-def mask_ignored(text: str) -> str:
-    return re.sub(IGNORED_TARGET_LINE, "", text)
-
-
-# --quiet is to suppress logs that differ between pysemgrep and osemgrep.
 LS = ["--x-ls"]
 
 
@@ -51,4 +37,4 @@ def test_exclude_include(run_semgrep_in_tmp: RunSemgrep, snapshot, options):
         target_name="exclude_include",
         assert_exit_code=None,
     )
-    snapshot.assert_match(mask_ignored(stdout), "files.list")
+    snapshot.assert_match(stdout, "files.list")
diff --git a/cli/tests/default/e2e/test_max_target_bytes.py b/cli/tests/default/e2e/test_max_target_bytes.py
index 6f7f54dd087a..17cd5c9a0626 100644
--- a/cli/tests/default/e2e/test_max_target_bytes.py
+++ b/cli/tests/default/e2e/test_max_target_bytes.py
@@ -2,26 +2,54 @@
 from tests.fixtures import RunSemgrep
 
 
-@pytest.mark.kinda_slow
-@pytest.mark.parametrize("max_bytes", ["1MB", "1.3R"])
-def test_max_target_bytes(run_semgrep_in_tmp: RunSemgrep, snapshot, max_bytes):
+def generic_test_max_target_bytes(
+    run_semgrep_in_tmp: RunSemgrep,
+    snapshot,
+    max_bytes,
+    check_results=True,
+    check_output=True,
+):
     stdout, stderr = run_semgrep_in_tmp(
         "rules/eqeq.yaml",
         target_name="basic",
         options=["--max-target-bytes", max_bytes],
         assert_exit_code=None,
     )
-    snapshot.assert_match(stdout, "results.json")
-    snapshot.assert_match(stderr, "error.txt")
+    if check_results:
+        snapshot.assert_match(stdout, "results.json")
+    if check_output:
+        snapshot.assert_match(stderr, "error.txt")
+
+
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize("max_bytes", ["1MB", "1.3R", "100B", "1B"])
+def test_max_target_bytes_results(run_semgrep_in_tmp: RunSemgrep, snapshot, max_bytes):
+    generic_test_max_target_bytes(
+        run_semgrep_in_tmp, snapshot, max_bytes, check_results=True, check_output=False
+    )
+
+
+@pytest.mark.kinda_slow
+@pytest.mark.parametrize("max_bytes", ["1MB", "1.3R"])
+def test_max_target_bytes_output(run_semgrep_in_tmp: RunSemgrep, snapshot, max_bytes):
+    generic_test_max_target_bytes(
+        run_semgrep_in_tmp, snapshot, max_bytes, check_results=False, check_output=True
+    )
 
 
+# The message we print is something like this:
+#
+#    Scanning 123 files with 456 Code rules
+#
+# pysemgrep prints an incorrect number of files because filtering based on
+# file size and other ignore mechanisms is mixed with rule-specific filtering.
+# It's hard to fix and osemgrep does this correctly, so we mark the test
+# as failing with pysemgrep.
+#
 @pytest.mark.kinda_slow
-@pytest.mark.parametrize(
-    "max_bytes",
-    [
-        "1B",
-    ],
-)
-@pytest.mark.osemfail
-def test_max_target_bytes_osemfail(run_semgrep_in_tmp: RunSemgrep, snapshot, max_bytes):
-    test_max_target_bytes(run_semgrep_in_tmp, snapshot, max_bytes)
+@pytest.mark.parametrize("max_bytes", ["100B", "1B"])
+@pytest.mark.pysemfail
+def test_max_target_bytes_output_pysemfail(
+    run_semgrep_in_tmp: RunSemgrep, snapshot, max_bytes
+):
+    test_max_target_bytes_output(run_semgrep_in_tmp, snapshot, max_bytes)
diff --git a/cli/tests/default/e2e/test_osemgrep_output.py b/cli/tests/default/e2e/test_osemgrep_output.py
index 35d25b783586..6023b97288a5 100644
--- a/cli/tests/default/e2e/test_osemgrep_output.py
+++ b/cli/tests/default/e2e/test_osemgrep_output.py
@@ -1,7 +1,5 @@
-from pathlib import Path
 from typing import List
 from typing import Optional
-from typing import Union
 
 import pytest
 from tests.fixtures import RunSemgrep
@@ -30,7 +28,7 @@ def test_sarif_output(
 ):
     rule, target = rule_and_target
     # The type annotation is there to make the type checker happy
-    options: Optional[List[Union[str, Path]]]
+    options: Optional[List[str]]
     if dataflow_traces:
         options = ["--verbose", "--use-osemgrep-sarif", "--dataflow-traces"]
     else:
diff --git a/cli/tests/default/e2e/test_permissions.py b/cli/tests/default/e2e/test_permissions.py
new file mode 100644
index 000000000000..4f7e50dbd62e
--- /dev/null
+++ b/cli/tests/default/e2e/test_permissions.py
@@ -0,0 +1,103 @@
+import os
+import sys
+
+import pytest
+from tests.fixtures import RunSemgrep
+
+from semgrep.constants import OutputFormat
+
+
+def prepare_workspace() -> None:
+    print(f"Current dir: {os.getcwd()}", file=sys.stderr)
+    print("Creating files with bad permissions in the workspace!", file=sys.stderr)
+
+    # Create file tree (assume 'targets/permissions/' already exists)
+    with open("targets/permissions/unreadable_file.py", "w") as file:
+        file.write("secret content\n")
+
+    with open("targets/permissions/readable_file.py", "w") as file:
+        file.write("a == a\n")
+
+    os.mkdir("targets/permissions/unreadable_subdir")
+
+    with open("targets/permissions/unreadable_subdir/file.py", "w") as file:
+        file.write("b == b\n")
+
+    # Make some nodes unreadable
+    os.chmod("targets/permissions/unreadable_subdir", 0o000)
+    os.chmod("targets/permissions/unreadable_file.py", 0o000)
+    os.system("ls -l targets/permissions >&2")
+
+
+# Restore permissions so that the function that cleans up the workspace
+# doesn't fail
+def teardown_workspace() -> None:
+    print(f"Current dir: {os.getcwd()}", file=sys.stderr)
+    print("Restoring good permissions to allow cleanup!", file=sys.stderr)
+    os.chmod("targets/permissions/unreadable_subdir", 0o700)
+    os.chmod("targets/permissions/unreadable_file.py", 0o600)
+
+
+# Check that target files lacking read permission are ignored gracefully.
+#
+# Pysemgrep fails to ignore the target without read permissions and passes
+# it to semgrep-core. This is why --x-ls fails the test. These targets
+# are checked again by semgrep-core which then excludes it correctly
+@pytest.mark.pysemfail
+@pytest.mark.kinda_slow
+def test_permissions_ls(run_semgrep_on_copied_files: RunSemgrep, snapshot):
+    stdout, stderr = run_semgrep_on_copied_files(
+        "rules/eqeq.yaml",  # ignored by --x-ls
+        options=["--x-ls"],
+        output_format=OutputFormat.TEXT,
+        prepare_workspace=prepare_workspace,
+        target_name="permissions",
+        teardown_workspace=teardown_workspace,
+    )
+    snapshot.assert_match(stdout, "files.list")
+
+
+# End-to-end test. This exercises a regular semgrep scan, in particular
+# one using semgrep-core underneath.
+#
+# The '--verbose' option causes the list of skipped targets to be included
+# in the JSON output. It differs between git and non-git projects due to
+# git doing some filtering of its own. Here, we run semgrep on a non-git
+# project and we expect all skipped targets to be reported with osemgrep
+# and pysemgrep.
+#
+def run_test_permissions_scan_full(
+    run_semgrep_on_copied_files: RunSemgrep, snapshot, verbose: bool
+):
+    options = None
+    if verbose:
+        options = ["--verbose"]
+    stdout, stderr = run_semgrep_on_copied_files(
+        "rules/eqeq.yaml",
+        options=options,
+        output_format=OutputFormat.JSON,
+        prepare_workspace=prepare_workspace,
+        target_name="permissions",
+        teardown_workspace=teardown_workspace,
+    )
+    snapshot.assert_match(stdout, "results.json")
+
+
+# pysemgrep fails because it doesn't report the skipped folder. This is benign.
+#
+@pytest.mark.pysemfail
+@pytest.mark.kinda_slow
+def test_permissions_scan_full_strict(
+    run_semgrep_on_copied_files: RunSemgrep, snapshot
+):
+    run_test_permissions_scan_full(run_semgrep_on_copied_files, snapshot, verbose=True)
+
+
+# Less strict: don't care about whether skipped files or folders are reported.
+#
+@pytest.mark.kinda_slow
+def test_permissions_scan_full_lax(run_semgrep_on_copied_files: RunSemgrep, snapshot):
+    run_test_permissions_scan_full(run_semgrep_on_copied_files, snapshot, verbose=False)
+
+
+# TODO: test on a git repo
diff --git a/cli/tests/default/unit/targeting/test_target_manager.py b/cli/tests/default/unit/targeting/test_target_manager.py
index 9fb6723679ed..7142dff0d533 100644
--- a/cli/tests/default/unit/targeting/test_target_manager.py
+++ b/cli/tests/default/unit/targeting/test_target_manager.py
@@ -77,7 +77,6 @@ def assert_path_sets_equal(a: Collection[Path], b: Collection[Path]):
     assert a_abs == b_abs
 
 
-@pytest.mark.quick
 @pytest.fixture(
     scope="session", params=["no-repo", "git-repo", "git-repo-with-ignores"]
 )
diff --git a/cli/tests/default/unit/test_filter_dependency_aware_rules.py b/cli/tests/default/unit/test_filter_dependency_aware_rules.py
index 08099a50f7d2..c3ef34756505 100644
--- a/cli/tests/default/unit/test_filter_dependency_aware_rules.py
+++ b/cli/tests/default/unit/test_filter_dependency_aware_rules.py
@@ -7,6 +7,8 @@
 from semgrep.run_scan import filter_dependency_aware_rules
 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 ManifestKind
+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 Transitivity
 from semgrep.subproject import LockfileDependencySource
@@ -105,8 +107,8 @@ def sample_resolved_deps():
 
     # Create LockfileDependencySource
     dependency_source = LockfileDependencySource(
+        manifest=(ManifestKind(value=Pipfile_()), Path("Pipfile")),
         package_manager_type=PackageManagerType.PIP,
-        manifest_path=Path("Pipfile"),
         lockfile_path=Path("Pipfile.lock"),
     )
 
diff --git a/cli/tests/default/unit/test_resolve_subprojects.py b/cli/tests/default/unit/test_resolve_subprojects.py
index 02e85fe1ea0b..48d6a997aec9 100644
--- a/cli/tests/default/unit/test_resolve_subprojects.py
+++ b/cli/tests/default/unit/test_resolve_subprojects.py
@@ -1,10 +1,13 @@
 from pathlib import Path
 from typing import List
+from unittest.mock import patch
 
 import pytest
 
+import semgrep.semgrep_interfaces.semgrep_output_v1 as out
 from semdep.subproject_matchers import ExactLockfileManifestMatcher
 from semdep.subproject_matchers import SubprojectMatcher
+from semgrep.resolve_subprojects import _resolve_dependency_source
 from semgrep.resolve_subprojects import find_subprojects
 from semgrep.subproject import LockfileDependencySource
 from semgrep.subproject import PackageManagerType
@@ -27,11 +30,13 @@
                     lockfile_name="requirements.txt",
                     manifest_name="requirements.in",
                     package_manager_type=PackageManagerType.PIP,
+                    manifest_kind=out.ManifestKind(value=out.RequirementsIn()),
                 ),
                 ExactLockfileManifestMatcher(
                     lockfile_name="requirements3.txt",
                     manifest_name="requirements.in",
                     package_manager_type=PackageManagerType.PIP,
+                    manifest_kind=out.ManifestKind(value=out.RequirementsIn()),
                 ),
             ],
             [
@@ -39,7 +44,10 @@
                     root_dir=Path(),
                     dependency_source=LockfileDependencySource(
                         package_manager_type=PackageManagerType.PIP,
-                        manifest_path=Path("requirements.in"),
+                        manifest=(
+                            out.ManifestKind(value=out.RequirementsIn()),
+                            Path("requirements.in"),
+                        ),
                         lockfile_path=Path("requirements.txt"),
                     ),
                 ),
@@ -47,7 +55,7 @@
                     root_dir=Path(),
                     dependency_source=LockfileDependencySource(
                         package_manager_type=PackageManagerType.PIP,
-                        manifest_path=None,
+                        manifest=(out.ManifestKind(value=out.RequirementsIn()), None),
                         lockfile_path=Path("requirements3.txt"),
                     ),
                 ),
@@ -65,11 +73,13 @@
                     lockfile_name="requirements.txt",
                     manifest_name="requirements.in",
                     package_manager_type=PackageManagerType.PIP,
+                    manifest_kind=out.ManifestKind(value=out.RequirementsIn()),
                 ),
                 ExactLockfileManifestMatcher(
                     lockfile_name="requirements3.txt",
                     manifest_name="requirements.in",
                     package_manager_type=PackageManagerType.PIP,
+                    manifest_kind=out.ManifestKind(value=out.RequirementsIn()),
                 ),
             ],
             [
@@ -77,7 +87,10 @@
                     root_dir=Path(),
                     dependency_source=LockfileDependencySource(
                         package_manager_type=PackageManagerType.PIP,
-                        manifest_path=Path("requirements.in"),
+                        manifest=(
+                            out.ManifestKind(value=out.RequirementsIn()),
+                            Path("requirements.in"),
+                        ),
                         lockfile_path=Path("requirements3.txt"),
                     ),
                 ),
@@ -92,3 +105,72 @@ def test_find_subprojects(
 ) -> None:
     result = find_subprojects(frozenset(file_paths), matchers)
     assert result == expected_subprojects
+
+
+@pytest.mark.quick
+@patch("semgrep.resolve_subprojects._resolve_dependencies_dynamically")
+def test_ptt_unconditionally_generates_dependency_graphs(
+    mock_dynamic_resolve, tmp_path
+) -> None:
+    manifest_file = open(tmp_path / "requirements.in", "w")
+    manifest_file.write("requests==2.25.1")
+    manifest_file.close()
+    lockfile_file = open(tmp_path / "requirements.txt", "w")
+    lockfile_file.write("requests==2.25.1")
+    lockfile_file.close()
+
+    mock_dynamic_resolve.return_value = ["mock_ecosystem", [], [], []]
+    dep_source = LockfileDependencySource(
+        package_manager_type=PackageManagerType.PIP,
+        manifest=(
+            out.ManifestKind(value=out.RequirementsIn()),
+            Path(tmp_path / "requirements.in"),
+        ),
+        lockfile_path=Path(tmp_path / "requirements.txt"),
+    )
+    _resolve_dependency_source(dep_source, True, True)
+
+    mock_dynamic_resolve.mock_assert_called_once_with(
+        Path("requirements.txt"), out.ManifestKind(value=out.RequirementsIn())
+    )
+
+
+@pytest.mark.quick
+@patch("semgrep.resolve_subprojects._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
+) -> None:
+    manifest_file = open(tmp_path / "requirements.in", "w")
+    manifest_file.write("requests==2.25.1")
+    manifest_file.close()
+    lockfile_file = open(tmp_path / "requirements.txt", "w")
+    lockfile_file.write("requests==2.25.1")
+    lockfile_file.close()
+
+    mock_dynamic_resolve.return_value = [None, [], [], []]
+    mock_parse_requirements.return_value = (
+        [
+            out.FoundDependency(
+                package="requests",
+                version="2.25.1",
+                ecosystem=out.Ecosystem(value=out.Pypi()),
+                allowed_hashes={},
+                transitivity=out.Transitivity(value=out.Direct()),
+            )
+        ],
+        [],
+    )
+    dep_source = LockfileDependencySource(
+        package_manager_type=PackageManagerType.PIP,
+        manifest=(
+            out.ManifestKind(value=out.RequirementsIn()),
+            Path(tmp_path / "requirements.in"),
+        ),
+        lockfile_path=Path(tmp_path / "requirements.txt"),
+    )
+    _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 e6083c0316c1..0397e973d6d7 100644
--- a/cli/tests/default/unit/test_subproject.py
+++ b/cli/tests/default/unit/test_subproject.py
@@ -2,6 +2,7 @@
 
 import pytest
 
+import semgrep.semgrep_interfaces.semgrep_output_v1 as out
 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
@@ -37,7 +38,10 @@ def test_finds_subproject_in_same_directory(self, tmp_path, monkeypatch):
             root_dir=Path("a/b/c"),
             dependency_source=LockfileDependencySource(
                 lockfile_path=lockfile_path,
-                manifest_path=Path("a/b/c/requirements.in"),
+                manifest=(
+                    out.ManifestKind(value=out.RequirementsIn()),
+                    Path("a/b/c/requirements.in"),
+                ),
                 package_manager_type=PackageManagerType.PIP,
             ),
             found_dependencies=ResolvedDependencies.from_found_dependencies([]),
@@ -49,7 +53,10 @@ def test_finds_subproject_in_same_directory(self, tmp_path, monkeypatch):
                 root_dir=Path("a/b"),
                 dependency_source=LockfileDependencySource(
                     lockfile_path=extra_lockfile_path,
-                    manifest_path=Path("a/b/requirements.in"),
+                    manifest=(
+                        out.ManifestKind(value=out.RequirementsIn()),
+                        Path("a/b/requirements.in"),
+                    ),
                     package_manager_type=PackageManagerType.PIP,
                 ),
                 found_dependencies=ResolvedDependencies.from_found_dependencies([]),
@@ -78,7 +85,10 @@ def test_finds_subproject_for_requested_ecosystem(self, tmp_path, monkeypatch):
             root_dir=Path("a/b"),
             dependency_source=LockfileDependencySource(
                 lockfile_path=lockfile_path,
-                manifest_path=Path("a/b/build.gradle"),
+                manifest=(
+                    out.ManifestKind(value=out.BuildGradle()),
+                    Path("a/b/build.gradle"),
+                ),
                 package_manager_type=PackageManagerType.GRADLE,
             ),
             found_dependencies=ResolvedDependencies.from_found_dependencies([]),
@@ -90,7 +100,10 @@ def test_finds_subproject_for_requested_ecosystem(self, tmp_path, monkeypatch):
                 root_dir=Path("a/b/c"),
                 dependency_source=LockfileDependencySource(
                     lockfile_path=extra_lockfile_path,
-                    manifest_path=Path("a/b/c/requirements.in"),
+                    manifest=(
+                        out.ManifestKind(value=out.RequirementsIn()),
+                        Path("a/b/c/requirements.in"),
+                    ),
                     package_manager_type=PackageManagerType.PIP,
                 ),
                 found_dependencies=ResolvedDependencies.from_found_dependencies([]),
@@ -126,7 +139,10 @@ def test_base_case(self, lockfile_path: Path):
             root_dir=Path("a/b/c"),
             dependency_source=LockfileDependencySource(
                 lockfile_path=lockfile_path,
-                manifest_path=Path("a/b/c/requirements.in"),
+                manifest=(
+                    out.ManifestKind(value=out.RequirementsIn()),
+                    Path("a/b/c/requirements.in"),
+                ),
                 package_manager_type=PackageManagerType.PIP,
             ),
             resolution_method=ResolutionMethod.LOCKFILE_PARSING,
@@ -175,12 +191,12 @@ def test_multiple_lockfiles(self):
             sources=(
                 LockfileDependencySource(
                     lockfile_path=lockfile_path,
-                    manifest_path=None,
+                    manifest=(out.ManifestKind(value=out.RequirementsIn()), None),
                     package_manager_type=PackageManagerType.PIP,
                 ),
                 LockfileDependencySource(
                     lockfile_path=extra_lockfile_path,
-                    manifest_path=None,
+                    manifest=(out.ManifestKind(value=out.RequirementsIn()), None),
                     package_manager_type=PackageManagerType.PIP,
                 ),
             )
@@ -226,7 +242,10 @@ def test_dep_missing_lockfile_path(self):
             root_dir=Path("a/b/c"),
             dependency_source=LockfileDependencySource(
                 lockfile_path=lockfile_path,
-                manifest_path=Path("a/b/c/requirements.in"),
+                manifest=(
+                    out.ManifestKind(value=out.RequirementsIn()),
+                    Path("a/b/c/requirements.in"),
+                ),
                 package_manager_type=PackageManagerType.PIP,
             ),
             resolution_method=ResolutionMethod.LOCKFILE_PARSING,
@@ -255,7 +274,7 @@ def test_base_case(self):
 
         source = LockfileDependencySource(
             lockfile_path=lockfile_path,
-            manifest_path=None,
+            manifest=(out.ManifestKind(value=out.RequirementsIn()), None),
             package_manager_type=PackageManagerType.PIP,
         )
 
@@ -274,12 +293,12 @@ def test_base_case(self):
             sources=(
                 LockfileDependencySource(
                     lockfile_path=lockfile_path,
-                    manifest_path=None,
+                    manifest=(out.ManifestKind(value=out.RequirementsIn()), None),
                     package_manager_type=PackageManagerType.PIP,
                 ),
                 LockfileDependencySource(
                     lockfile_path=extra_lockfile_path,
-                    manifest_path=None,
+                    manifest=(out.ManifestKind(value=out.RequirementsIn()), None),
                     package_manager_type=PackageManagerType.PIP,
                 ),
             )
diff --git a/cli/tests/default/unit/test_subproject_matchers.py b/cli/tests/default/unit/test_subproject_matchers.py
index 1476104d5c41..23148f19ebe0 100644
--- a/cli/tests/default/unit/test_subproject_matchers.py
+++ b/cli/tests/default/unit/test_subproject_matchers.py
@@ -3,6 +3,7 @@
 
 import pytest
 
+import semgrep.semgrep_interfaces.semgrep_output_v1 as out
 from semdep.subproject_matchers import ExactLockfileManifestMatcher
 from semdep.subproject_matchers import filter_dependency_source_files
 from semdep.subproject_matchers import PatternLockfileMatcher
@@ -20,6 +21,7 @@ def test_lockfile_match(self):
             package_manager_type=PackageManagerType.PIPENV,
             lockfile_name="Pipfile.lock",
             manifest_name="Pipfile",
+            manifest_kind=out.ManifestKind(value=out.Pipfile_()),
         )
 
         assert matcher.is_match(Path("Pipfile.lock")) is True
@@ -32,6 +34,7 @@ def test_manifest_match(self):
             package_manager_type=PackageManagerType.PIPENV,
             lockfile_name="Pipfile.lock",
             manifest_name="Pipfile",
+            manifest_kind=out.ManifestKind(value=out.Pipfile_()),
         )
 
         assert matcher.is_match(Path("Pipfile")) is True
@@ -52,6 +55,7 @@ def test_make_subprojects(
             lockfile_name="Pipfile.lock",
             manifest_name="Pipfile",
             package_manager_type=PackageManagerType.PIPENV,
+            manifest_kind=out.ManifestKind(value=out.Pipfile_()),
         )
 
         assert matcher.is_match(manifest_path)
@@ -66,10 +70,14 @@ def test_make_subprojects(
         assert subproject.root_dir == tmp_path
         assert isinstance(subproject.dependency_source, LockfileDependencySource)
         assert subproject.dependency_source.lockfile_path == lockfile_path
+        _, subproject_manifest_path = subproject.dependency_source.manifest or (
+            None,
+            None,
+        )
         if create_manifest:
-            assert subproject.dependency_source.manifest_path == manifest_path
+            assert subproject_manifest_path == manifest_path
         else:
-            assert subproject.dependency_source.manifest_path is None
+            assert subproject_manifest_path is None
 
 
 class TestPatternLockfileMatcher:
@@ -79,6 +87,7 @@ def test_is_match(self):
             lockfile_pattern="*requirements*.txt",
             manifest_name="requirements.in",
             package_manager_type=PackageManagerType.PIP,
+            manifest_kind=out.ManifestKind(value=out.RequirementsIn()),
         )
 
         # Basic cases
@@ -133,6 +142,7 @@ def test_make_subprojects(
             lockfile_pattern="*requirements*.txt",
             manifest_name="requirements.in",
             package_manager_type=PackageManagerType.PIP,
+            manifest_kind=out.ManifestKind(value=out.RequirementsIn()),
         )
 
         # expect the matcher to create four subprojects, with or without manifests
@@ -148,14 +158,18 @@ def test_make_subprojects(
         assert len(subprojects) == 4
         for subproject in subprojects:
             assert isinstance(subproject.dependency_source, LockfileDependencySource)
+            _, manifest_path = subproject.dependency_source.manifest or (
+                None,
+                None,
+            )
             expected_root, expected_manifest = test_data[
                 subproject.dependency_source.lockfile_path
             ]
             assert subproject.root_dir == expected_root
             if with_manifest:
-                assert subproject.dependency_source.manifest_path == expected_manifest
+                assert manifest_path == expected_manifest
             else:
-                assert subproject.dependency_source.manifest_path is None
+                assert manifest_path is None
 
 
 class TestRequirementsLockfileMatcher:
@@ -217,7 +231,10 @@ def test_is_match(self) -> None:
                         root_dir=Path(),
                         dependency_source=LockfileDependencySource(
                             package_manager_type=PackageManagerType.PIP,
-                            manifest_path=Path("requirements.in"),
+                            manifest=(
+                                out.ManifestKind(value=out.RequirementsIn()),
+                                Path("requirements.in"),
+                            ),
                             lockfile_path=Path("requirements.txt"),
                         ),
                     ),
@@ -225,7 +242,10 @@ def test_is_match(self) -> None:
                         root_dir=Path("a/b/c"),
                         dependency_source=LockfileDependencySource(
                             package_manager_type=PackageManagerType.PIP,
-                            manifest_path=Path("a/b/c/requirements.in"),
+                            manifest=(
+                                out.ManifestKind(value=out.RequirementsIn()),
+                                Path("a/b/c/requirements.in"),
+                            ),
                             lockfile_path=Path("a/b/c/requirements.txt"),
                         ),
                     ),
@@ -243,7 +263,10 @@ def test_is_match(self) -> None:
                         root_dir=Path(),
                         dependency_source=LockfileDependencySource(
                             package_manager_type=PackageManagerType.PIP,
-                            manifest_path=Path("requirements3.in"),
+                            manifest=(
+                                out.ManifestKind(value=out.RequirementsIn()),
+                                Path("requirements3.in"),
+                            ),
                             lockfile_path=Path("requirements3.txt"),
                         ),
                     ),
@@ -251,7 +274,10 @@ def test_is_match(self) -> None:
                         root_dir=Path("a/b/c"),
                         dependency_source=LockfileDependencySource(
                             package_manager_type=PackageManagerType.PIP,
-                            manifest_path=Path("a/b/c/requirements.in"),
+                            manifest=(
+                                out.ManifestKind(value=out.RequirementsIn()),
+                                Path("a/b/c/requirements.in"),
+                            ),
                             lockfile_path=Path("a/b/c/requirements3.txt"),
                         ),
                     ),
@@ -270,12 +296,18 @@ def test_is_match(self) -> None:
                             sources=(
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("requirements-dev.txt"),
                                 ),
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("requirements-prod.txt"),
                                 ),
                             )
@@ -296,12 +328,18 @@ def test_is_match(self) -> None:
                             sources=(
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("dev-requirements.txt"),
                                 ),
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("prod-requirements.txt"),
                                 ),
                             )
@@ -316,7 +354,10 @@ def test_is_match(self) -> None:
                         root_dir=Path(),
                         dependency_source=LockfileDependencySource(
                             package_manager_type=PackageManagerType.PIP,
-                            manifest_path=Path("requirements.in"),
+                            manifest=(
+                                out.ManifestKind(value=out.RequirementsIn()),
+                                Path("requirements.in"),
+                            ),
                             lockfile_path=Path("requirements_lock.txt"),
                         ),
                     )
@@ -335,12 +376,18 @@ def test_is_match(self) -> None:
                             sources=(
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("requirements/dev.txt"),
                                 ),
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("requirements/prod.txt"),
                                 ),
                             )
@@ -362,12 +409,18 @@ def test_is_match(self) -> None:
                             sources=(
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements.in"),
+                                    ),
                                     lockfile_path=Path("requirements/dev.txt"),
                                 ),
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=Path("requirements/prod.in"),
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        Path("requirements/prod.in"),
+                                    ),
                                     lockfile_path=Path("requirements/prod.txt"),
                                 ),
                             )
@@ -387,12 +440,18 @@ def test_is_match(self) -> None:
                             sources=(
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=None,
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        None,
+                                    ),
                                     lockfile_path=Path("requirements/dev.txt"),
                                 ),
                                 LockfileDependencySource(
                                     package_manager_type=PackageManagerType.PIP,
-                                    manifest_path=None,
+                                    manifest=(
+                                        out.ManifestKind(value=out.RequirementsIn()),
+                                        None,
+                                    ),
                                     lockfile_path=Path("requirements/prod.txt"),
                                 ),
                             )
diff --git a/cli/tests/fixtures.py b/cli/tests/fixtures.py
index b35efa3e58df..b0a3c4559def 100644
--- a/cli/tests/fixtures.py
+++ b/cli/tests/fixtures.py
@@ -1,25 +1,44 @@
-# TODO: pad: Not sure why this needs to be in a separate file and how it works.
+# Type definitions used in tests
+#
+# These definitions are presumably not placed in 'conftest.py' so they can be
+# shared across semgrep and semgrep-proprietary, each having their own
+# 'conftest.py'.
+# TODO: confirm the assumption above
+#
 from __future__ import annotations
 
 from pathlib import Path
+from typing import Callable
+from typing import Optional
 from typing import Protocol
 from typing import TYPE_CHECKING
 
 from semgrep.constants import OutputFormat
 
+
+# TYPE_CHECKING is a constant that ensures some imports (e.g., from
+# tests.conftest) are only processed during type checking (e.g., by
+# mypy), and not at runtime.
+#
+# This is presumably what allows for cyclic dependency between the two
+# modules for typechecking purposes.
+# TODO: simplify!
+#
 if TYPE_CHECKING:
     from tests.conftest import SemgrepResult
 
 
+# The type of run_semgrep functions defined in a project's 'conftest.py'.
+# Run 'make typecheck' to run mypy on the project.
+#
 class RunSemgrep(Protocol):
     def __call__(
-        # if you change these args, mypy will require updating tests.conftest._run_semgrep too
         self,
         config: str | Path | list[str] | None = None,
         *,
         subcommand: str | None = None,
         target_name: str | None = "basic",
-        options: list[str | Path] | None = None,
+        options: Optional[list[str]] = None,
         output_format: OutputFormat | None = OutputFormat.JSON,
         strict: bool = True,
         quiet: bool = False,
@@ -31,5 +50,7 @@ def __call__(
         stdin: str | None = None,
         clean_fingerprint: bool = True,
         use_click_runner: bool = False,
+        prepare_workspace: Callable[[], None] = lambda: None,
+        teardown_workspace: Callable[[], None] = lambda: None,
     ) -> SemgrepResult:
         ...
diff --git a/cli/tests/semgrep_runner.py b/cli/tests/semgrep_runner.py
index 05bfa9779ce3..bcfc9fbfc6e1 100644
--- a/cli/tests/semgrep_runner.py
+++ b/cli/tests/semgrep_runner.py
@@ -32,7 +32,7 @@
 ##############################################################################
 
 # Environment variable that trigger the use of osemgrep
-_USE_OSEMGREP = "PYTEST_USE_OSEMGREP" in os.environ
+USE_OSEMGREP = "PYTEST_USE_OSEMGREP" in os.environ
 
 # The --experimental is to force the use of osemgrep.
 _OSEMGREP_EXTRA_ARGS = ["--experimental"]
@@ -42,14 +42,22 @@
 # which is a new behavior in osemgrep.
 _OSEMGREP_SCAN_EXTRA_ARGS = _OSEMGREP_EXTRA_ARGS + ["--project-root", "."]
 
-_SEMGREP_PATH = str((Path(__file__).parent.parent / "bin" / "semgrep").absolute())
+_SEMGREP_PATH = str(
+    (
+        Path(__file__).parent.parent
+        / "src"
+        / "semgrep"
+        / "console_scripts"
+        / "entrypoint.py"
+    ).absolute()
+)
 
 # Exported constant, convenient to use in a list context.
 # This is not safe to use if you are going to append any subcommands after!
 # For instance, SEMGREP_BASE_SCAN_COMMAND + ["logout"] will fail with osemgrep,
 # because the subcommand must come first.
 SEMGREP_BASE_SCAN_COMMAND: List[str] = (
-    [_SEMGREP_PATH] + _OSEMGREP_SCAN_EXTRA_ARGS if _USE_OSEMGREP else [_SEMGREP_PATH]
+    [_SEMGREP_PATH] + _OSEMGREP_SCAN_EXTRA_ARGS if USE_OSEMGREP else [_SEMGREP_PATH]
 )
 
 SEMGREP_BASE_SCAN_COMMAND_STR: str = " ".join(SEMGREP_BASE_SCAN_COMMAND)
@@ -60,7 +68,7 @@
 
 
 def mk_semgrep_base_command(subcommand: str, args: List[str]):
-    args = _OSEMGREP_EXTRA_ARGS + args if _USE_OSEMGREP else args
+    args = _OSEMGREP_EXTRA_ARGS + args if USE_OSEMGREP else args
     return [_SEMGREP_PATH] + [subcommand] + args
 
 
@@ -134,7 +142,7 @@ class SemgrepRunner:
     """
 
     def __init__(self, env=None, mix_stderr=True, use_click_runner=False):
-        if use_click_runner and _USE_OSEMGREP:
+        if use_click_runner and USE_OSEMGREP:
             use_click_runner = False
             print("disabling Click_runner use because of PYTEST_USE_OSEMGREP")
         self._use_click_runner = use_click_runner
diff --git a/libs/commons/tests/Unit_List_.ml b/libs/commons/tests/Unit_List_.ml
index 203775fd1846..26e367be7e5b 100644
--- a/libs/commons/tests/Unit_List_.ml
+++ b/libs/commons/tests/Unit_List_.ml
@@ -26,6 +26,8 @@ let test_iter_with_view_into_neighbor_elements name list =
 
 let test_flatten () =
   let list = [ [ 1; 2; 3 ]; []; [ 4 ]; [ 5; 6 ] ] in
+  (* nosemgrep is ok here as we are comparing against STD List.flatten *)
+  (* nosemgrep: no-list-concat *)
   Alcotest.(check (list int)) __LOC__ (List.flatten list) (List_.flatten list)
 
 let test_append () =
diff --git a/libs/commons/tests/Unit_commons.ml b/libs/commons/tests/Unit_commons.ml
index 080859e32339..7e0d5399dd94 100644
--- a/libs/commons/tests/Unit_commons.ml
+++ b/libs/commons/tests/Unit_commons.ml
@@ -43,7 +43,7 @@ let test_common_map =
     let input = List.init len (fun i -> i) in
     let f x = x + 1 in
     let acc = ref [] in
-    let expected_output = List.map f input in
+    let expected_output = List_.map f input in
     let output =
       List_.map
         (fun x ->
diff --git a/src/analyzing/tests/Test_analyze_generic.ml b/src/analyzing/tests/Test_analyze_generic.ml
index 3c3fe5da132e..91aee05c605a 100644
--- a/src/analyzing/tests/Test_analyze_generic.ml
+++ b/src/analyzing/tests/Test_analyze_generic.ml
@@ -34,6 +34,7 @@ let test_il_generic ~parse_program file =
   let ast = parse_program file in
   let lang = Lang.lang_of_filename_exn file in
   Naming_AST.resolve lang ast;
+  Implicit_return.mark_implicit_return lang ast;
 
   let v =
     object
@@ -56,10 +57,13 @@ let test_cfg_il ~parse_program file =
   let ast = parse_program file in
   let lang = Lang.lang_of_filename_exn file in
   Naming_AST.resolve lang ast;
+  Implicit_return.mark_implicit_return lang ast;
   Visit_function_defs.visit
-    (fun _ fdef ->
-      let IL.{ fparams = _; fcfg } = CFG_build.cfg_of_gfdef lang fdef in
-      Display_IL.display_cfg fcfg)
+    (fun _ent fdef ->
+      let IL.{ params = _; cfg; lambdas = _ } =
+        CFG_build.cfg_of_gfdef lang fdef
+      in
+      Display_IL.display_cfg cfg)
     ast
 
 module F2 = IL
@@ -81,11 +85,11 @@ let test_dfg_svalue ~parse_program file =
       inherit [_] AST_generic.iter_no_id_info
 
       method! visit_function_definition _ def =
-        let fdef_cfg = CFG_build.cfg_of_gfdef lang def in
+        let fun_cfg = CFG_build.cfg_of_gfdef lang def in
         UCommon.pr2 "Constness";
-        let mapping = Dataflow_svalue.fixpoint lang fdef_cfg in
-        Dataflow_svalue.update_svalue fdef_cfg.fcfg mapping;
-        DataflowY.display_mapping fdef_cfg.fcfg mapping
+        let mapping = Dataflow_svalue.fixpoint lang fun_cfg in
+        Dataflow_svalue.update_svalue fun_cfg.cfg mapping;
+        DataflowY.display_mapping fun_cfg.cfg mapping
           (Dataflow_var_env.env_to_str (Pretty_print_AST.svalue_to_string lang));
         let s = AST_generic.show_any (S (H.funcbody_to_stmt def.fbody)) in
         UCommon.pr2 s
diff --git a/src/engine/tests/Test_dataflow_tainting.ml b/src/engine/tests/Test_dataflow_tainting.ml
index dc8eedf1588d..ee9cdac32228 100644
--- a/src/engine/tests/Test_dataflow_tainting.ml
+++ b/src/engine/tests/Test_dataflow_tainting.ml
@@ -25,13 +25,13 @@ let pr2_ranges (file : Fpath.t) (rwms : RM.t list) : unit =
 let test_tainting (lang : Lang.t) (_file : Fpath.t) options config def =
   UCommon.pr2 "\nDataflow";
   UCommon.pr2 "--------";
-  let flow, _effects_IGNORED, mapping =
+  let fcfg, _effects_IGNORED, mapping =
     Match_tainting_mode.check_fundef lang options config None
       AST_to_IL.empty_ctx
       (Dataflow_tainting.mk_empty_java_props_cache ())
       def
   in
-  DataflowX.display_mapping flow mapping Taint_lval_env.to_string
+  DataflowX.display_mapping fcfg.cfg mapping Taint_lval_env.to_string
 
 let test_dfg_tainting rules_file file =
   let rules_file = Fpath.v rules_file in
diff --git a/src/engine/tests/Test_engine.ml b/src/engine/tests/Test_engine.ml
index 6cce02c03795..f6708a95884b 100644
--- a/src/engine/tests/Test_engine.ml
+++ b/src/engine/tests/Test_engine.ml
@@ -259,7 +259,7 @@ let make_test_rule_file ?(fail_callback = fun _i m -> Alcotest.fail m)
         Test_utils.compare_fixes ~file:target res.matches;
 
         check_profiling rule_file target res;
-        let actual_errors = res.matches |> List.map TCM.location_of_pm in
+        let actual_errors = res.matches |> List_.map TCM.location_of_pm in
         actual_errors
         |> List.iter (fun (_, line) ->
                Logs.debug (fun m -> m "match at line: %d" line));
diff --git a/tests/patterns/ocaml/local_open.TODO b/tests/patterns/ocaml/local_open.TODO
new file mode 100644
index 000000000000..1a22f5d70108
--- /dev/null
+++ b/tests/patterns/ocaml/local_open.TODO
@@ -0,0 +1,21 @@
+(* TODO: This test is renamed to .TODO because it fails in JS for unknown
+ * reasons
+ *)
+
+let test1 () =
+  (* ERROR: match *)
+  Foo.bar()
+
+let test2 () =
+  (* ERROR: match *)
+  Foo.(bar())
+
+(* TODO? the match should be on the call to bar(), not the let open *)
+let test3 () =
+  (* ERROR: match *)
+  let open Foo in
+  bar()
+
+(* without open Foo, this does not match *)
+let test4 () =
+  bar()
diff --git a/tests/patterns/ocaml/local_open.sgrep b/tests/patterns/ocaml/local_open.sgrep
new file mode 100644
index 000000000000..16b2fe125194
--- /dev/null
+++ b/tests/patterns/ocaml/local_open.sgrep
@@ -0,0 +1,1 @@
+Foo.bar ...
diff --git a/tests/rules/cp_lambda.cpp b/tests/rules/cp_lambda.cpp
new file mode 100644
index 000000000000..02c7b8e54437
--- /dev/null
+++ b/tests/rules/cp_lambda.cpp
@@ -0,0 +1,12 @@
+void test() {
+    int v = 42;
+    // ruleid: test
+    run([v]() { foo(v); });
+
+void test() {
+    int v = 42;
+    // v is modified in the lambda
+    run([&v](int x) { v = x; });
+    // ok: test
+    foo(v);
+}
diff --git a/tests/rules/cp_lambda.yaml b/tests/rules/cp_lambda.yaml
new file mode 100644
index 000000000000..0a70279ea710
--- /dev/null
+++ b/tests/rules/cp_lambda.yaml
@@ -0,0 +1,7 @@
+rules:
+  - id: test
+    message: Semgrep found a match
+    languages:
+      - cpp
+    severity: WARNING
+    pattern: foo(42)
diff --git a/tests/rules/sym_prop_lambda.py b/tests/rules/sym_prop_lambda.py
new file mode 100644
index 000000000000..29e9ef511ec2
--- /dev/null
+++ b/tests/rules/sym_prop_lambda.py
@@ -0,0 +1,5 @@
+def test():
+    y = x
+    z = bar(y)
+    #ruleid: test
+    run(lambda: foo(z))
diff --git a/tests/rules/sym_prop_lambda.yaml b/tests/rules/sym_prop_lambda.yaml
new file mode 100644
index 000000000000..b62eae26f51e
--- /dev/null
+++ b/tests/rules/sym_prop_lambda.yaml
@@ -0,0 +1,9 @@
+rules:
+  - id: test
+    options:
+      symbolic_propagation: true
+    pattern: foo(bar(x))
+    languages:
+      - python
+    severity: INFO
+    message: Test
diff --git a/tests/rules/taint_best_fit_sink9.java b/tests/rules/taint_best_fit_sink9.java
new file mode 100644
index 000000000000..ff1f66964b3a
--- /dev/null
+++ b/tests/rules/taint_best_fit_sink9.java
@@ -0,0 +1,30 @@
+package test.sqlInjection;
+
+import java.util.Map;
+import org.sasanlabs.vulnerability.utils.Constants;
+import org.springframework.http.ResponseEntity;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.web.bind.annotation.RequestParam;
+
+public class ErrorBasedSQLInjectionVulnerability {
+
+    public ResponseEntity<String> doesCarInformationExistsLevel4(
+            @RequestParam Map<String, String> queryParams) {
+        final String id = queryParams.get(Constants.ID).replaceAll("'", "");
+        ResponseEntity<String> response =
+                applicationJdbcTemplate.query(
+                        (conn) ->
+                                // Want "best fit matches" to be computed once per
+                                // function definition and then used as-is for the
+                                // analysis of the lambdas. If we computed them
+                                // separately for lambdas, then this statement would
+                                // be a best match. In this particular case it may
+                                // be convenient, but it's better to rely on proper
+                                // inter-proc analysis.
+                                conn.prepareStatement(
+                                        "select * from cars where id='" + id + "'"));
+
+        return response;
+    }
+
+}
diff --git a/tests/rules/taint_best_fit_sink9.yaml b/tests/rules/taint_best_fit_sink9.yaml
new file mode 100644
index 000000000000..1c9be30ad638
--- /dev/null
+++ b/tests/rules/taint_best_fit_sink9.yaml
@@ -0,0 +1,20 @@
+rules:
+- id: test
+  languages:
+    - java
+  severity: ERROR
+  message: Test
+  mode: taint
+  pattern-sources:
+    - patterns:
+      - pattern-inside: |
+          public ResponseEntity<String> $FUNC(...,
+          @RequestParam Map<$TYPE, $TYPE> $VALUE,...) {
+          ...
+          }
+      - pattern: $VALUE.get(...)
+  pattern-sinks:
+    - patterns:
+      - pattern: |
+          applicationJdbcTemplate.query($QUERY,...)          
+      - focus-metavariable: $QUERY
diff --git a/tests/semgrep-rules b/tests/semgrep-rules
index 0bba56c058e5..2ad051cdb3b2 160000
--- a/tests/semgrep-rules
+++ b/tests/semgrep-rules
@@ -1,1 +1,1 @@
-Subproject commit 0bba56c058e51adfb683954acafc8afb02e41467
+Subproject commit 2ad051cdb3b2959393baf5a9afbea0d5f9478328
diff --git a/tests/snapshots/semgrep-core/3d6289360fec/stdout b/tests/snapshots/semgrep-core/3d6289360fec/stdout
index be4ce0b6c824..e316af7e9de1 100644
--- a/tests/snapshots/semgrep-core/3d6289360fec/stdout
+++ b/tests/snapshots/semgrep-core/3d6289360fec/stdout
@@ -10,6 +10,5 @@ Initialized empty Git repository in<MASKED>
  create mode 100644 bin/ignore-me
  create mode 100644 bin/ignore-me-not
 RUN semgrep scan --experimental --x-ls .
-selected .gitignore
-selected bin/ignore-me-not
-ignored bin/ignore-me [semgrepignore_patterns_match]
+.gitignore
+bin/ignore-me-not
diff --git a/tests/snapshots/semgrep-core/84cced48c6d1/stdxxx b/tests/snapshots/semgrep-core/84cced48c6d1/stdxxx
index 0de51fbfc34f..4897905c6317 100644
--- a/tests/snapshots/semgrep-core/84cced48c6d1/stdxxx
+++ b/tests/snapshots/semgrep-core/84cced48c6d1/stdxxx
@@ -54,6 +54,7 @@ More information: https://semgrep.dev/docs/metrics
 Rules:
 - eqeq-bad
 
+[<MASKED TIMESTAMP>][INFO]: scan subcommand: 1 valid rules, 0 invalid rules, 3 targets
 [<MASKED TIMESTAMP>][INFO]: running the semgrep engine
 
 
diff --git a/tests/snapshots/semgrep-core/b52c24bc6a7d/stdout b/tests/snapshots/semgrep-core/b52c24bc6a7d/stdout
index c2b9f7309b00..28b7569ff90c 100644
--- a/tests/snapshots/semgrep-core/b52c24bc6a7d/stdout
+++ b/tests/snapshots/semgrep-core/b52c24bc6a7d/stdout
@@ -14,8 +14,6 @@ Initialized empty Git repository in<MASKED>
  create mode 100644 b
  create mode 100644 c
 RUN semgrep scan --experimental --x-ls .
-selected .gitignore
-selected .semgrepignore
-selected c
-ignored a [semgrepignore_patterns_match]
-ignored b [semgrepignore_patterns_match]
+.gitignore
+.semgrepignore
+c
diff --git a/tests/snapshots/semgrep-core/f7c140d27c6f/stdout b/tests/snapshots/semgrep-core/f7c140d27c6f/stdout
index a672d468065b..2400ac03f248 100644
--- a/tests/snapshots/semgrep-core/f7c140d27c6f/stdout
+++ b/tests/snapshots/semgrep-core/f7c140d27c6f/stdout
@@ -8,5 +8,4 @@ Initialized empty Git repository in<MASKED>
  create mode 100644 .gitignore
  create mode 100644 a
 RUN semgrep scan --experimental --x-ls .
-selected .gitignore
-ignored a [semgrepignore_patterns_match]
+.gitignore