Repository: andreoliwa/nitpick (language: python)

Issue:
Fix JSON files automatically
## Problem

I want to automatically fix JSON files, adding keys and values (strings, lists or objects), so I don't have to manually edit them.

## Possible solution

- [x] Change the plugin to allow editing JSON files


Discussion / hints:

Fix patch:
diff --git a/README.rst b/README.rst
index 9ab5ef0b..6e7b496c 100644
--- a/README.rst
+++ b/README.rst
@@ -102,7 +102,7 @@ Implemented
      - ✅
    * - `Any JSON file <https://nitpick.rtfd.io/en/latest/plugins.html#json-files>`_
      - ✅
-     - `#358 <https://github.com/andreoliwa/nitpick/issues/358>`_ 🚧
+     - ✅
    * - `Any text file <https://nitpick.rtfd.io/en/latest/plugins.html#text-files>`_
      - ✅
      - ❌
@@ -114,7 +114,7 @@ Implemented
      - ✅
    * - `package.json <https://nitpick.rtfd.io/en/latest/examples.html#example-package-json>`_
      - ✅
-     - `#358 <https://github.com/andreoliwa/nitpick/issues/358>`_ 🚧
+     - ✅
    * - `.pre-commit-config.yaml <https://nitpick.rtfd.io/en/latest/plugins.html#pre-commit-config-yaml>`_
      - ✅
      - `#282 <https://github.com/andreoliwa/nitpick/issues/282>`_ 🚧
diff --git a/docs/generate_rst.py b/docs/generate_rst.py
index fc50bf8c..faec0a0c 100644
--- a/docs/generate_rst.py
+++ b/docs/generate_rst.py
@@ -148,13 +148,13 @@ def row(self) -> Tuple[str, str, str]:
 
 IMPLEMENTED_FILE_TYPES: Set[FileType] = {
     FileType("Any INI file", f"{READ_THE_DOCS_URL}plugins.html#ini-files", True, True),
-    FileType("Any JSON file", f"{READ_THE_DOCS_URL}plugins.html#json-files", True, 358),
+    FileType("Any JSON file", f"{READ_THE_DOCS_URL}plugins.html#json-files", True, True),
     FileType("Any text file", f"{READ_THE_DOCS_URL}plugins.html#text-files", True, False),
     FileType("Any TOML file", f"{READ_THE_DOCS_URL}plugins.html#toml-files", True, True),
     FileType(EDITOR_CONFIG, f"{READ_THE_DOCS_URL}examples.html#example-editorconfig", True, True),
     FileType(PRE_COMMIT_CONFIG_YAML, f"{READ_THE_DOCS_URL}plugins.html#pre-commit-config-yaml", True, 282),
     FileType(PYLINTRC, f"{READ_THE_DOCS_URL}plugins.html#ini-files", True, True),
-    FileType(PACKAGE_JSON, f"{READ_THE_DOCS_URL}examples.html#example-package-json", True, 358),
+    FileType(PACKAGE_JSON, f"{READ_THE_DOCS_URL}examples.html#example-package-json", True, True),
     FileType(PYPROJECT_TOML, f"{READ_THE_DOCS_URL}plugins.html#toml-files", True, True),
     FileType("requirements.txt", f"{READ_THE_DOCS_URL}plugins.html#text-files", True, False),
     FileType(SETUP_CFG, f"{READ_THE_DOCS_URL}plugins.html#ini-files", True, True),
diff --git a/poetry.lock b/poetry.lock
index b4419c38..c251c594 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -63,17 +63,17 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 
 [[package]]
 name = "attrs"
-version = "21.2.0"
+version = "21.4.0"
 description = "Classes Without Boilerplate"
 category = "main"
 optional = false
 python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
 
 [package.extras]
-dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"]
+dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit", "cloudpickle"]
 docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]
-tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"]
-tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"]
+tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]
+tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "cloudpickle"]
 
 [[package]]
 name = "autorepr"
@@ -837,6 +837,17 @@ pytest = ">=4.6"
 [package.extras]
 testing = ["fields", "hunter", "process-tests", "six", "pytest-xdist", "virtualenv"]
 
+[[package]]
+name = "pytest-datadir"
+version = "1.3.1"
+description = "pytest plugin for test data directories and files"
+category = "main"
+optional = true
+python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
+
+[package.dependencies]
+pytest = ">=2.7.0"
+
 [[package]]
 name = "pytest-socket"
 version = "0.4.1"
@@ -1265,12 +1276,12 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=2.4)", "pytest-flake8", "pytes
 [extras]
 doc = ["sphinx", "sphinx_rtd_theme", "sphobjinv"]
 lint = ["pylint"]
-test = ["pytest", "pytest-cov", "testfixtures", "responses", "freezegun", "pytest-testmon", "pytest-watch", "pytest-socket"]
+test = ["pytest", "pytest-cov", "testfixtures", "responses", "freezegun", "pytest-testmon", "pytest-watch", "pytest-socket", "pytest-datadir"]
 
 [metadata]
 lock-version = "1.1"
 python-versions = "^3.6.1"
-content-hash = "2ac51583dbbb57040c2c815ad7a752932936e77819ff5c99645a055078dfe9b2"
+content-hash = "c37ea5ad5424900d8f8b27a3b704b5a7dcca49a6289ebf06669f8d75280af521"
 
 [metadata.files]
 aiocontextvars = [
@@ -1298,8 +1309,8 @@ atomicwrites = [
     {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"},
 ]
 attrs = [
-    {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"},
-    {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"},
+    {file = "attrs-21.4.0-py2.py3-none-any.whl", hash = "sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4"},
+    {file = "attrs-21.4.0.tar.gz", hash = "sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"},
 ]
 autorepr = [
     {file = "autorepr-0.3.0-py2-none-any.whl", hash = "sha256:c34567e4073630feb52d9c788fc198085e9e9de4817e3b93b7c4c534fc689f11"},
@@ -1759,6 +1770,10 @@ pytest-cov = [
     {file = "pytest-cov-3.0.0.tar.gz", hash = "sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"},
     {file = "pytest_cov-3.0.0-py3-none-any.whl", hash = "sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6"},
 ]
+pytest-datadir = [
+    {file = "pytest-datadir-1.3.1.tar.gz", hash = "sha256:d3af1e738df87515ee509d6135780f25a15959766d9c2b2dbe02bf4fb979cb18"},
+    {file = "pytest_datadir-1.3.1-py2.py3-none-any.whl", hash = "sha256:1847ed0efe0bc54cac40ab3fba6d651c2f03d18dd01f2a582979604d32e7621e"},
+]
 pytest-socket = [
     {file = "pytest-socket-0.4.1.tar.gz", hash = "sha256:b5abcfd14d9c03e7d4e2875bb5e190af28bf5b4162d0a83e3d7aee6d79f1c85d"},
     {file = "pytest_socket-0.4.1-py3-none-any.whl", hash = "sha256:e3bc6cc9c2534289c6a5b9748ca733f6ae8c8d34f1fdab5115ca17a47860bf5e"},
diff --git a/pyproject.toml b/pyproject.toml
index 7d317eff..1ef46268 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -64,28 +64,35 @@ marshmallow-polyfield = "^5.10"
 identify = "*"
 "more-itertools" = "*"
 pluggy = "*"
+pydantic = "*"
+autorepr = "*"
+loguru = "*"
+ConfigUpdater = "*"
+cachy = "*"
+importlib-resources = { version = "*", python = ">=3.6, <3.9" }
+
+# TODO: Move to dependency groups once the feature is on a stable version of Poetry
+#  https://python-poetry.org/docs/master/managing-dependencies/#dependency-groups
+# Group: lint
 pylint = { version = "*", optional = true }
+# Group: test
 pytest = { version = "*", optional = true }
 pytest-cov = { version = "*", optional = true }
 testfixtures = { version = "*", optional = true }
-freezegun = { version = "*", optional = true }
 responses = { version = "*", optional = true }
+freezegun = { version = "*", optional = true }
 pytest-testmon = { version = "*", optional = true }
 pytest-watch = { version = "*", optional = true }
 pytest-socket = { version = "*", optional = true }
+pytest-datadir = { version = "*", optional = true }
+# Group: doc
 sphinx = { version = "*", optional = true }
 sphinx_rtd_theme = { version = "*", optional = true }
 sphobjinv = { version = "*", optional = true }
-pydantic = "*"
-autorepr = "*"
-loguru = "*"
-ConfigUpdater = "*"
-cachy = "*"
-importlib-resources = { version = "*", python = ">=3.6, <3.9" }
 
 [tool.poetry.extras]
 lint = ["pylint"]
-test = ["pytest", "pytest-cov", "testfixtures", "responses", "freezegun", "pytest-testmon", "pytest-watch", "pytest-socket"]
+test = ["pytest", "pytest-cov", "testfixtures", "responses", "freezegun", "pytest-testmon", "pytest-watch", "pytest-socket", "pytest-datadir"]
 doc = ["sphinx", "sphinx_rtd_theme", "sphobjinv"]
 
 [tool.poetry.dev-dependencies]
diff --git a/src/nitpick/formats.py b/src/nitpick/formats.py
index 22808ac7..4d45aa15 100644
--- a/src/nitpick/formats.py
+++ b/src/nitpick/formats.py
@@ -296,7 +296,8 @@ def load(self) -> bool:
             if isinstance(self._data, BaseFormat):
                 self._reformatted = self._data.reformatted
             else:
-                self._reformatted = json.dumps(self._data, sort_keys=True, indent=2)
+                # Every file should end with a blank line
+                self._reformatted = json.dumps(self._data, sort_keys=True, indent=2) + "\n"
         self._loaded = True
         return True
 
diff --git a/src/nitpick/generic.py b/src/nitpick/generic.py
index 8a274ba4..53d9314d 100644
--- a/src/nitpick/generic.py
+++ b/src/nitpick/generic.py
@@ -27,6 +27,7 @@ def get_subclasses(cls):
     return subclasses
 
 
+# TODO: move flatten() and unflatten() to DictBlender: they depend on each other and keep state between calls.
 def flatten(dict_, parent_key="", separator=".", current_lists=None) -> JsonDict:
     """Flatten a nested dict.
 
@@ -116,8 +117,14 @@ def unflatten(dict_, separator=".", sort=True) -> OrderedDict:
     return items
 
 
-class MergeDict:
-    """A dictionary that can merge other dictionaries into it."""
+class DictBlender:
+    """A blender of dictionaries: keep adding dictionaries and mix them all at the end.
+
+    .. note::
+
+        This class intentionally doesn't inherit from the standard ``dict()``.
+        It's an unnecessary hassle to override and deal with all those magic dunder methods.
+    """
 
     def __init__(self, original_dict: JsonDict = None) -> None:
         self._all_flattened: OrderedDict = OrderedDict()
@@ -129,8 +136,8 @@ def add(self, other: JsonDict) -> None:
         flattened_other = flatten(other, separator=SEPARATOR_FLATTEN, current_lists=self._current_lists)
         self._all_flattened.update(flattened_other)
 
-    def merge(self, sort=True) -> JsonDict:
-        """Merge the dictionaries, replacing values with identical keys and extending lists."""
+    def mix(self, sort=True) -> JsonDict:
+        """Mix all dictionaries, replacing values with identical keys and extending lists."""
         return unflatten(self._all_flattened, separator=SEPARATOR_FLATTEN, sort=sort)
 
 
diff --git a/src/nitpick/plugins/base.py b/src/nitpick/plugins/base.py
index 56b67c24..fdbb14c1 100644
--- a/src/nitpick/plugins/base.py
+++ b/src/nitpick/plugins/base.py
@@ -68,7 +68,7 @@ def get_compiled_jmespath_filenames(cls):
 
     def entry_point(self) -> Iterator[Fuss]:
         """Entry point of the Nitpick plugin."""
-        self.init()
+        self.post_init()
 
         should_exist: bool = bool(self.info.project.nitpick_files_section.get(self.filename, True))
         if self.file_path.exists() and not should_exist:
@@ -96,8 +96,12 @@ def _enforce_file_configuration(self):
             if fuss:
                 yield fuss
 
-    def init(self):
-        """Hook for plugin initialization after the instance was created."""
+    def post_init(self):
+        """Hook for plugin initialization after the instance was created.
+
+        The name mimics ``__post_init__()`` on dataclasses, without the magic double underscores:
+        `Post-init processing <https://docs.python.org/3/library/dataclasses.html#post-init-processing>`_
+        """
 
     def _suggest_when_file_not_found(self):
         suggestion = self.initial_contents
diff --git a/src/nitpick/plugins/ini.py b/src/nitpick/plugins/ini.py
index 4504166e..6341f600 100644
--- a/src/nitpick/plugins/ini.py
+++ b/src/nitpick/plugins/ini.py
@@ -50,7 +50,7 @@ class IniPlugin(NitpickPlugin):
     updater: ConfigUpdater
     comma_separated_values: Set[str]
 
-    def init(self):
+    def post_init(self):
         """Post initialization after the instance was created."""
         self.updater = ConfigUpdater()
         self.comma_separated_values = set(self.nitpick_file_dict.get(COMMA_SEPARATED_VALUES, []))
diff --git a/src/nitpick/plugins/json.py b/src/nitpick/plugins/json.py
index aeba2961..b6da6415 100644
--- a/src/nitpick/plugins/json.py
+++ b/src/nitpick/plugins/json.py
@@ -1,22 +1,23 @@
 """JSON files."""
 import json
+from itertools import chain
 from typing import Iterator, Optional, Type
 
 from loguru import logger
-from sortedcontainers import SortedDict
 
 from nitpick import fields
-from nitpick.formats import JSONFormat
-from nitpick.generic import flatten, unflatten
+from nitpick.formats import BaseFormat, JSONFormat
+from nitpick.generic import DictBlender, flatten, unflatten
 from nitpick.plugins import hookimpl
 from nitpick.plugins.base import NitpickPlugin
 from nitpick.plugins.info import FileInfo
 from nitpick.schemas import BaseNitpickSchema
 from nitpick.typedefs import JsonDict
-from nitpick.violations import Fuss, ViolationEnum
+from nitpick.violations import Fuss, SharedViolations, ViolationEnum
 
 KEY_CONTAINS_KEYS = "contains_keys"
 KEY_CONTAINS_JSON = "contains_json"
+VALUE_PLACEHOLDER = "<some value here>"
 
 
 class JSONFileSchema(BaseNitpickSchema):
@@ -26,12 +27,6 @@ class JSONFileSchema(BaseNitpickSchema):
     contains_json = fields.Dict(fields.NonEmptyString, fields.JSONString)
 
 
-class Violations(ViolationEnum):
-    """Violations for this plugin."""
-
-    MISSING_KEYS = (348, " has missing keys:")
-
-
 class JSONPlugin(NitpickPlugin):
     """Enforce configurations for any JSON file.
 
@@ -42,54 +37,63 @@ class JSONPlugin(NitpickPlugin):
     validation_schema = JSONFileSchema
     identify_tags = {"json"}
     violation_base_code = 340
-
-    SOME_VALUE_PLACEHOLDER = "<some value here>"
+    can_fix = True
 
     def enforce_rules(self) -> Iterator[Fuss]:
         """Enforce rules for missing keys and JSON content."""
-        yield from self._check_contained_keys()
-        yield from self._check_contained_json()
-
-    def get_suggested_json(self, raw_actual: JsonDict = None) -> JsonDict:
-        """Return the suggested JSON based on actual values."""
-        actual = set(flatten(raw_actual).keys()) if raw_actual else set()
-        expected = set(self.expected_config.get(KEY_CONTAINS_KEYS) or [])
-        # TODO: include "contains_json" keys in the suggestion as well
-        missing = expected - actual
-        if not missing:
-            return {}
-
-        return SortedDict(unflatten({key: self.SOME_VALUE_PLACEHOLDER for key in missing}))
-
-    @property
-    def initial_contents(self) -> str:
-        """Suggest the initial content for this missing file."""
-        suggestion = self.get_suggested_json()
-        return JSONFormat(data=suggestion).reformatted if suggestion else ""
-
-    def _check_contained_keys(self) -> Iterator[Fuss]:
-        json_fmt = JSONFormat(path=self.file_path)
-        suggested_json = self.get_suggested_json(json_fmt.as_data)
-        if not suggested_json:
-            return
-        yield self.reporter.make_fuss(Violations.MISSING_KEYS, JSONFormat(data=suggested_json).reformatted)
-
-    def _check_contained_json(self) -> Iterator[Fuss]:
-        actual_fmt = JSONFormat(path=self.file_path)
-        expected = {}
+        actual = JSONFormat(path=self.file_path)
+        final_dict: Optional[JsonDict] = flatten(actual.as_data) if self.fix else None
+
+        comparison = actual.compare_with_flatten(self.expected_dict_from_contains_keys())
+        if comparison.missing:
+            yield from self.report(SharedViolations.MISSING_VALUES, final_dict, comparison.missing)
+
+        comparison = actual.compare_with_flatten(self.expected_dict_from_contains_json())
+        if comparison.has_changes:
+            yield from chain(
+                self.report(SharedViolations.DIFFERENT_VALUES, final_dict, comparison.diff),
+                self.report(SharedViolations.MISSING_VALUES, final_dict, comparison.missing),
+            )
+
+        if self.fix and self.dirty and final_dict:
+            self.file_path.write_text(JSONFormat(data=unflatten(final_dict)).reformatted)
+
+    def expected_dict_from_contains_keys(self):
+        """Expected dict created from "contains_keys" values."""
+        return unflatten({key: VALUE_PLACEHOLDER for key in set(self.expected_config.get(KEY_CONTAINS_KEYS) or [])})
+
+    def expected_dict_from_contains_json(self):
+        """Expected dict created from "contains_json" values."""
+        expected_config = {}
         # TODO: accept key as a jmespath expression, value is valid JSON
         for key, json_string in (self.expected_config.get(KEY_CONTAINS_JSON) or {}).items():
             try:
-                expected[key] = json.loads(json_string)
+                expected_config[key] = json.loads(json_string)
             except json.JSONDecodeError as err:
                 # This should not happen, because the style was already validated before.
                 # Maybe the NIP??? code was disabled by the user?
                 logger.error(f"{err} on {KEY_CONTAINS_JSON} while checking {self.file_path}")
                 continue
+        return expected_config
 
-        yield from self.warn_missing_different(
-            JSONFormat(data=actual_fmt.as_data).compare_with_dictdiffer(expected, unflatten)
-        )
+    def report(self, violation: ViolationEnum, final_dict: Optional[JsonDict], change: Optional[BaseFormat]):
+        """Report a violation while optionally modifying the JSON dict."""
+        if not change:
+            return
+        if final_dict:
+            final_dict.update(flatten(change.as_data))
+            self.dirty = True
+        yield self.reporter.make_fuss(violation, change.reformatted, prefix="", fixed=self.fix)
+
+    @property
+    def initial_contents(self) -> str:
+        """Suggest the initial content for this missing file."""
+        suggestion = DictBlender(self.expected_dict_from_contains_keys())
+        suggestion.add(self.expected_dict_from_contains_json())
+        json_as_string = JSONFormat(data=suggestion.mix()).reformatted if suggestion else ""
+        if self.fix:
+            self.file_path.write_text(json_as_string)
+        return json_as_string
 
 
 @hookimpl
diff --git a/src/nitpick/plugins/toml.py b/src/nitpick/plugins/toml.py
index cef7fe6b..37c96a15 100644
--- a/src/nitpick/plugins/toml.py
+++ b/src/nitpick/plugins/toml.py
@@ -56,22 +56,22 @@ def enforce_rules(self) -> Iterator[Fuss]:
         if self.fix and self.dirty:
             self.file_path.write_text(dumps(document))
 
-    def report(self, violation: ViolationEnum, document: Optional[TOMLDocument], change_dict: Optional[BaseFormat]):
+    def report(self, violation: ViolationEnum, document: Optional[TOMLDocument], change: Optional[BaseFormat]):
         """Report a violation while optionally modifying the TOML document."""
-        if not change_dict:
+        if not change:
             return
         if document:
-            change_toml(document, change_dict.as_data)
+            change_toml(document, change.as_data)
             self.dirty = True
-        yield self.reporter.make_fuss(violation, change_dict.reformatted.strip(), prefix="", fixed=self.fix)
+        yield self.reporter.make_fuss(violation, change.reformatted.strip(), prefix="", fixed=self.fix)
 
     @property
     def initial_contents(self) -> str:
         """Suggest the initial content for this missing file."""
-        rv = TOMLFormat(data=self.expected_config).reformatted
+        toml_as_string = TOMLFormat(data=self.expected_config).reformatted
         if self.fix:
-            self.file_path.write_text(rv)
-        return rv
+            self.file_path.write_text(toml_as_string)
+        return toml_as_string
 
 
 @hookimpl
diff --git a/src/nitpick/style/core.py b/src/nitpick/style/core.py
index 35308963..bec0deaa 100644
--- a/src/nitpick/style/core.py
+++ b/src/nitpick/style/core.py
@@ -1,7 +1,7 @@
 """Style files."""
 import os
 from collections import OrderedDict
-from dataclasses import dataclass, field
+from dataclasses import dataclass
 from functools import lru_cache
 from pathlib import Path
 from typing import Dict, Iterator, Optional, Set, Tuple, Type
@@ -27,7 +27,7 @@
 )
 from nitpick.exceptions import QuitComplainingError, pretty_exception
 from nitpick.formats import TOMLFormat
-from nitpick.generic import MergeDict, is_url, search_dict
+from nitpick.generic import DictBlender, is_url, search_dict
 from nitpick.plugins.base import NitpickPlugin
 from nitpick.plugins.info import FileInfo
 from nitpick.project import Project, glob_files
@@ -49,17 +49,12 @@ class Style:  # pylint: disable=too-many-instance-attributes
     offline: bool
     cache_option: str
 
-    _all_styles: MergeDict = field(init=False, default_factory=MergeDict)
-    _already_included: Set[str] = field(init=False, default_factory=set)
-    _first_full_path: str = ""
-
-    _style_fetcher_manager: StyleFetcherManager = field(init=False)
-    _config_validator: ConfigValidator = field(init=False)
-
-    _dynamic_schema_class: type = field(init=False, default=BaseStyleSchema)
-
     def __post_init__(self) -> None:
         """Initialize dependant fields."""
+        self._blender: DictBlender = DictBlender()
+        self._already_included: Set[str] = set()
+        self._first_full_path: str = ""
+        self._dynamic_schema_class: type = BaseStyleSchema
         self._style_fetcher_manager = StyleFetcherManager(self.offline, self.cache_dir, self.cache_option)
         self._config_validator = ConfigValidator(self.project)
         self.rebuild_dynamic_schema()
@@ -131,7 +126,7 @@ def _include_style(self, style_uri):
                 StyleViolations.INVALID_CONFIG, flatten_marshmallow_errors(validation_errors)
             )
 
-        self._all_styles.add(toml_dict)
+        self._blender.add(toml_dict)
 
         sub_styles: StrOrList = search_dict(NITPICK_STYLES_INCLUDE_JMEX, toml_dict, [])
         if sub_styles:
@@ -196,7 +191,7 @@ def get_style_path(self, style_uri: str) -> Tuple[Optional[Path], str]:
 
     def merge_toml_dict(self) -> JsonDict:
         """Merge all included styles into a TOML (actually JSON) dictionary."""
-        merged_dict = self._all_styles.merge()
+        merged_dict = self._blender.mix()
         # TODO: check if the merged style file is still needed
         merged_style_path: Path = self.cache_dir / MERGED_STYLE_TOML
         toml = TOMLFormat(data=merged_dict)
diff --git a/tasks.py b/tasks.py
index fa52450a..bd97884e 100644
--- a/tasks.py
+++ b/tasks.py
@@ -80,19 +80,32 @@ def macos(ctx, enable: bool):
         else:
             ctx.run("sed -i '' 's/platform = darwin/platform = linux/g' tox.ini")
 
-    def _python_versions(self) -> List[str]:
+    def _python_versions_old_to_new(self) -> List[str]:
         """Python versions executed in tox."""
-        return list(reversed([v for v in self._parser["tox"]["envlist"].split(",") if v.startswith("py")]))
+        versions = []
+        for py_plus_version_without_dot in self._parser["tox"]["envlist"].split(","):
+            if not py_plus_version_without_dot.startswith("py"):
+                continue
+
+            major = py_plus_version_without_dot[2]
+            minor = py_plus_version_without_dot[3:]
+            versions.append(f"{major}.{minor}")
+        return list(reversed(versions))
 
     @property
     def minimum_python_version(self) -> str:
         """Minimum Python version."""
-        return self._python_versions()[-1]
+        return self._python_versions_old_to_new()[0]
 
     @property
     def stable_python_version(self) -> str:
         """Stable Python version."""
-        return self._python_versions()[-2]
+        return self._as_tox_env(self._python_versions_old_to_new()[-2])
+
+    @staticmethod
+    def _as_tox_env(python_version_with_dot: str) -> str:
+        no_dot = python_version_with_dot.replace(".", "")
+        return f"py{no_dot}"
 
 
 @task(help={"deps": "Poetry dependencies", "hooks": "pre-commit hooks"})
@@ -119,11 +132,18 @@ def install(ctx, deps=True, hooks=False):
         "coverage": "Run the HTML coverage report",
         "browse": "Browse the HTML coverage report",
         "watch": "Watch modified files and run tests with testmon",
+        "reset": "Force testmon to update its data before watching tests",
     }
 )
-def test(ctx, coverage=False, browse=False, watch=False):
-    """Run tests and coverage using the commands from tox config."""
+def test(ctx, coverage=False, browse=False, watch=False, reset=False):
+    """Run tests and coverage using the commands from tox config.
+
+    `Testmon <https://github.com/tarpas/pytest-testmon>`_
+    """
     tox = ToxCommands()
+    if reset:
+        ctx.run(f"poetry run {tox.pytest_command} --testmon-noselect")
+        watch = True
     if watch:
         ctx.run('poetry run ptw --runner "pytest --testmon"')
         return


Test patch:
diff --git a/tests/fixtures/hello.py b/tests/data/hello.py
similarity index 100%
rename from tests/fixtures/hello.py
rename to tests/data/hello.py
diff --git a/tests/data/sample-package-json-style.toml b/tests/data/sample-package-json-style.toml
new file mode 100644
index 00000000..5e70c334
--- /dev/null
+++ b/tests/data/sample-package-json-style.toml
@@ -0,0 +1,7 @@
+["package.json"]
+contains_keys = ["name", "version", "repository.type", "repository.url", "release.plugins"]
+
+["package.json".contains_json]
+commitlint = """
+  { "extends": [ "@commitlint/config-conventional" ] }
+"""
diff --git a/tests/helpers.py b/tests/helpers.py
index bdf70b20..2b35999c 100644
--- a/tests/helpers.py
+++ b/tests/helpers.py
@@ -29,7 +29,6 @@
 from nitpick.typedefs import Flake8Error, PathOrStr, StrOrList
 from nitpick.violations import Fuss, Reporter
 
-FIXTURES_DIR: Path = Path(__file__).parent / "fixtures"
 STYLES_DIR: Path = Path(__file__).parent.parent / "styles"
 
 # Non-breaking space
@@ -68,7 +67,7 @@ def __init__(self, tmp_path: Path, **kwargs) -> None:
         if kwargs.get("setup_py", True):
             self.save_file("setup.py", "x = 1")
 
-    def create_symlink(self, link_name: str, target_dir: Path = None, target_file: str = None) -> "ProjectMock":
+    def create_symlink(self, link_name: str, target_dir: Path, target_file: str = None) -> "ProjectMock":
         """Create a symlink to a target file.
 
         :param link_name: Link file name.
@@ -76,7 +75,7 @@ def create_symlink(self, link_name: str, target_dir: Path = None, target_file: s
         :param target_file: Target file name (default: source file name).
         """
         path: Path = self.root_dir / link_name
-        full_source_path = Path(target_dir or FIXTURES_DIR) / (target_file or link_name)
+        full_source_path = Path(target_dir) / (target_file or link_name)
         if not full_source_path.exists():
             raise RuntimeError(f"Source file does not exist: {full_source_path}")
         path.symlink_to(full_source_path)
diff --git a/tests/test_generic.py b/tests/test_generic.py
index 13562618..8d6aa07e 100644
--- a/tests/test_generic.py
+++ b/tests/test_generic.py
@@ -7,7 +7,7 @@
 from testfixtures import compare
 
 from nitpick.constants import EDITOR_CONFIG, TOX_INI
-from nitpick.generic import MergeDict, flatten, get_subclasses, relative_to_current_dir
+from nitpick.generic import DictBlender, flatten, get_subclasses, relative_to_current_dir
 from tests.helpers import assert_conditions
 
 
@@ -63,14 +63,14 @@ def test_flatten():
 
 def test_merge_dicts_extending_lists():
     """Merge dictionaries extending lists."""
-    merged = MergeDict({"parent": {"brother": 1, "sister": 2}})
-    merged.add({"parent": {"other": 3}})
-    assert_conditions(merged.merge() == {"parent": {"brother": 1, "sister": 2, "other": 3}})
+    blender = DictBlender({"parent": {"brother": 1, "sister": 2}})
+    blender.add({"parent": {"other": 3}})
+    assert_conditions(blender.mix() == {"parent": {"brother": 1, "sister": 2, "other": 3}})
 
-    merged = MergeDict({"box": {"colors": ["blue", "yellow"], "cutlery": ("fork",)}})
-    merged.add({"box": {"colors": ("white",), "cutlery": ["knife", "spoon"]}})
+    blender = DictBlender({"box": {"colors": ["blue", "yellow"], "cutlery": ("fork",)}})
+    blender.add({"box": {"colors": ("white",), "cutlery": ["knife", "spoon"]}})
     assert_conditions(
-        merged.merge() == {"box": {"colors": ["blue", "yellow", "white"], "cutlery": ["fork", "knife", "spoon"]}}
+        blender.mix() == {"box": {"colors": ["blue", "yellow", "white"], "cutlery": ["fork", "knife", "spoon"]}}
     )
 
 
diff --git a/tests/test_json.py b/tests/test_json.py
index 8cadc8ff..256996e5 100644
--- a/tests/test_json.py
+++ b/tests/test_json.py
@@ -1,68 +1,100 @@
 """JSON tests."""
 import warnings
 
-from nitpick.constants import READ_THE_DOCS_URL
-from nitpick.violations import Fuss
+import pytest
+
+from nitpick.constants import PACKAGE_JSON, READ_THE_DOCS_URL
+from nitpick.plugins.json import JSONPlugin
+from nitpick.violations import Fuss, SharedViolations
 from tests.helpers import ProjectMock
 
-PACKAGE_JSON_STYLE = '''
-    ["package.json"]
-    contains_keys = ["name", "version", "repository.type", "repository.url", "release.plugins"]
-
-    ["package.json".contains_json]
-    commitlint = """
-      {
-        "extends": [
-          "@commitlint/config-conventional"
-        ]
-      }
-    """
-'''
 
+@pytest.fixture()
+def package_json_style(shared_datadir) -> str:
+    """A sample style for package.json."""
+    return (shared_datadir / "sample-package-json-style.toml").read_text()
 
-def test_suggest_initial_contents(tmp_path):
+
+def test_suggest_initial_contents(tmp_path, package_json_style):
     """Suggest initial contents for missing JSON file."""
-    ProjectMock(tmp_path).named_style("package-json", PACKAGE_JSON_STYLE).pyproject_toml(
+    expected_package_json = """
+        {
+          "commitlint": {
+            "extends": [
+              "@commitlint/config-conventional"
+            ]
+          },
+          "name": "<some value here>",
+          "release": {
+            "plugins": "<some value here>"
+          },
+          "repository": {
+            "type": "<some value here>",
+            "url": "<some value here>"
+          },
+          "version": "<some value here>"
+        }
+    """
+    ProjectMock(tmp_path).named_style("package-json", package_json_style).pyproject_toml(
         """
         [tool.nitpick]
         style = ["package-json"]
         """
     ).api_check_then_fix(
         Fuss(
-            False,
-            "package.json",
+            True,
+            PACKAGE_JSON,
             341,
             " was not found. Create it with this content:",
-            """
-            {
-              "name": "<some value here>",
-              "release": {
-                "plugins": "<some value here>"
-              },
-              "repository": {
-                "type": "<some value here>",
-                "url": "<some value here>"
-              },
-              "version": "<some value here>"
-            }
-            """,
+            expected_package_json,
         )
+    ).assert_file_contents(
+        PACKAGE_JSON, expected_package_json
     )
 
 
-def test_json_file_contains_keys(tmp_path):
-    """Test if JSON file contains keys."""
-    ProjectMock(tmp_path).named_style("package-json", PACKAGE_JSON_STYLE,).pyproject_toml(
+def test_missing_different_values_with_contains_json_with_contains_keys(tmp_path, package_json_style):
+    """Test missing and different values with "contains_json" and "contains_keys"."""
+    expected_package_json = """
+        {
+          "commitlint": {
+            "extends": [
+              "@commitlint/config-conventional"
+            ]
+          },
+          "name": "myproject",
+          "release": {
+            "plugins": "<some value here>"
+          },
+          "repository": {
+            "type": "<some value here>",
+            "url": "<some value here>"
+          },
+          "something": "else",
+          "version": "0.0.1"
+        }
+    """
+    ProjectMock(tmp_path).named_style("package-json", package_json_style).pyproject_toml(
         """
         [tool.nitpick]
         style = ["package-json"]
         """
-    ).save_file("package.json", '{"name": "myproject", "version": "0.0.1"}').api_check_then_fix(
+    ).save_file(
+        PACKAGE_JSON,
+        """
+        {
+          "name": "myproject",
+          "version": "0.0.1",
+          "something": "else",
+          "commitlint":{"extends":["wrong-plugin-should-be-replaced","another-wrong-plugin"]}
+        }
+        """,
+    ).api_check_then_fix(
         Fuss(
-            False,
-            "package.json",
-            348,
-            " has missing keys:",
+            True,
+            PACKAGE_JSON,
+            SharedViolations.MISSING_VALUES.code + JSONPlugin.violation_base_code,
+            " has missing values:",
             """
             {
               "release": {
@@ -76,10 +108,10 @@ def test_json_file_contains_keys(tmp_path):
             """,
         ),
         Fuss(
-            False,
-            "package.json",
-            348,
-            " has missing values:",
+            True,
+            PACKAGE_JSON,
+            SharedViolations.DIFFERENT_VALUES.code + JSONPlugin.violation_base_code,
+            " has different values. Use this:",
             """
             {
               "commitlint": {
@@ -90,11 +122,13 @@ def test_json_file_contains_keys(tmp_path):
             }
             """,
         ),
+    ).assert_file_contents(
+        PACKAGE_JSON, expected_package_json
     )
 
 
-def test_missing_different_values(tmp_path):
-    """Test missing and different values on the JSON file."""
+def test_missing_different_values_with_contains_json_without_contains_keys(tmp_path):
+    """Test missing and different values with "contains_json", without "contains_keys"."""
     ProjectMock(tmp_path).style(
         '''
         ["my.json".contains_json]
@@ -106,9 +140,9 @@ def test_missing_different_values(tmp_path):
         '''
     ).save_file("my.json", '{"name":"myproject","formatting":{"on.the":"actual file"}}').api_check_then_fix(
         Fuss(
-            False,
+            True,
             "my.json",
-            348,
+            SharedViolations.MISSING_VALUES.code + JSONPlugin.violation_base_code,
             " has missing values:",
             """
             {
@@ -137,9 +171,9 @@ def test_missing_different_values(tmp_path):
             """,
         ),
         Fuss(
-            False,
+            True,
             "my.json",
-            349,
+            SharedViolations.DIFFERENT_VALUES.code + JSONPlugin.violation_base_code,
             " has different values. Use this:",
             """
             {
@@ -149,6 +183,35 @@ def test_missing_different_values(tmp_path):
             }
             """,
         ),
+    ).assert_file_contents(
+        "my.json",
+        """
+        {
+          "formatting": {
+            "doesnt": "matter",
+            "here": true,
+            "on.the": "config file"
+          },
+          "name": "myproject",
+          "some.dotted.root.key": {
+            "content": [
+              "should",
+              "be",
+              "here"
+            ],
+            "dotted.subkeys": [
+              "should be preserved",
+              {
+                "complex.weird.sub": {
+                  "objects": true
+                },
+                "even.with": 1
+              }
+            ],
+            "valid": "JSON"
+          }
+        }
+        """,
     )
 
 
diff --git a/tests/test_project.py b/tests/test_project.py
index 3637c837..dbd7c8e6 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -1,5 +1,6 @@
 """Config tests."""
 import os
+import shutil
 
 import pytest
 import responses
@@ -35,9 +36,12 @@ def test_singleton():
     assert "This class cannot be instantiated directly" in str(err)
 
 
-def test_no_root_dir_with_python_file(tmp_path):
+def test_no_root_dir_with_python_file(tmp_path, shared_datadir):
     """No root dir with Python file."""
-    project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=False).create_symlink("hello.py")
+    hello_py = tmp_path / "hello.py"
+    shutil.copy(shared_datadir / "hello.py", hello_py)
+    project = ProjectMock(tmp_path, pyproject_toml=False, setup_py=False)
+    project.files_to_lint.append(hello_py)
     error = f"NIP101 {ProjectViolations.NO_ROOT_DIR.message}"
     project.flake8().assert_single_error(error).cli_run(error, exit_code=2).cli_ls(error, exit_code=2)