Skip to content

Commit f4b05af

Browse files
committed
Carry the project lessons forward after the module folder is prepared
Inheritance never happened in a real render. Every module in the d365 chain ended up with only the lessons it had learned itself, and none of the ones the module before it had passed on. The copy was made in the renderer, before the render started. The first action of the render is PrepareRepositories, which deletes the module folder outright to start it clean - and .memory lives inside that folder. So the file was copied in, deleted seconds later, and the consolidating call at the end of the first functionality wrote a fresh one containing only what that module had just learned. The copy now happens inside PrepareRepositories, immediately after the wipe it has to survive. MemoryManager holds the predecessor's folder so it owns both ends of the copy rather than being handed them. The unit tests did not catch this because they only ever exercised the two steps separately: resolving the predecessor, and copying between two folders. Both were correct in isolation. There is now a test that runs PrepareRepositories against a folder holding an inherited file and asserts the wipe still happens and the file is still there afterwards; it fails if the call is removed. That test is built on SimpleNamespace rather than MagicMock on purpose. A MagicMock attribute satisfies os.fspath, so a path the test forgot to set turns into a real directory named after the mock instead of an error - which is how an earlier version of it quietly created a MagicMock/ tree in the repository root.
1 parent 91b9f20 commit f4b05af

5 files changed

Lines changed: 76 additions & 18 deletions

File tree

memory_management.py

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -60,37 +60,43 @@ def fetch_memory_files(memory_folder: str) -> tuple[list[str], dict[str, str]]:
6060
console.debug(f"Loaded {len(memory_files_content)} memory files.")
6161
return memory_files, memory_files_content
6262

63-
@staticmethod
64-
def inherit_project_lessons(predecessor_memory_folder: Optional[str], memory_folder: str) -> None:
63+
def inherit_project_lessons(self) -> None:
6564
"""Carry the project lessons forward from the module rendered before this one.
6665
6766
Copied rather than merged: the predecessor's file already contains everything inherited from further
6867
back, so one copy carries the whole chain. Overwriting is deliberate - the file is a snapshot of what
6968
the chain knew at this point, not an accumulation of this module's own history.
7069
71-
With no predecessor, or none that has a file, whatever is already here is left alone. That lets the
72-
first module in a chain keep accumulating across renders, which is where a project's toolchain facts
73-
get their first home.
70+
Called once the module's folders have been prepared, not before. Preparing them deletes the module
71+
folder outright, `.memory` included, so a copy made any earlier does not survive to be read.
7472
"""
75-
if not predecessor_memory_folder:
73+
if not self.predecessor_memory_folder:
7674
return
7775

78-
source = os.path.join(predecessor_memory_folder, PROJECT_LESSONS_FILE_NAME)
76+
source = os.path.join(self.predecessor_memory_folder, PROJECT_LESSONS_FILE_NAME)
7977
if not os.path.exists(source):
78+
console.debug(f"No project lessons to inherit from {self.predecessor_memory_folder}.")
8079
return
8180

82-
destination = os.path.join(memory_folder, PROJECT_LESSONS_FILE_NAME)
81+
destination = os.path.join(self.memory_folder, PROJECT_LESSONS_FILE_NAME)
8382
try:
84-
os.makedirs(memory_folder, exist_ok=True)
83+
os.makedirs(self.memory_folder, exist_ok=True)
8584
shutil.copyfile(source, destination)
8685
console.debug(f"Inherited project lessons from {source}.")
8786
except OSError as exception:
8887
console.debug(f"Could not inherit project lessons from {source}: {exception}.")
8988

90-
def __init__(self, codeplain_api, memory_folder: str, project_memory_folder: str):
89+
def __init__(
90+
self,
91+
codeplain_api,
92+
memory_folder: str,
93+
project_memory_folder: str,
94+
predecessor_memory_folder: Optional[str] = None,
95+
):
9196
self.codeplain_api = codeplain_api
9297
self.memory_folder = memory_folder
9398
self.project_memory_folder = project_memory_folder
99+
self.predecessor_memory_folder = predecessor_memory_folder
94100

95101
def consolidate_lessons(self, render_context: RenderContext) -> None:
96102
"""Extract what transfers to later functionalities, then discard the journal it came from.

module_renderer.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -141,9 +141,7 @@ def _render_module(
141141
self.codeplainAPI,
142142
plain_module.module_memory_folder,
143143
plain_module.project_memory_folder,
144-
)
145-
MemoryManager.inherit_project_lessons(
146-
self._predecessor_memory_folder(plain_module), plain_module.module_memory_folder
144+
self._predecessor_memory_folder(plain_module),
147145
)
148146
render_context = self._build_render_context_for_module(
149147
plain_module,

render_machine/actions/prepare_repositories.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,10 @@ def execute(self, render_context: RenderContext, _previous_action_payload: Any |
3535
file_utils.delete_folder(render_context.plain_module.module_folder)
3636
render_context.plain_module.seed_module_metadata()
3737

38+
# After the wipe, not before it: the module folder that was just deleted holds .memory, so the
39+
# project lessons have to be carried forward once there is somewhere for them to live.
40+
render_context.memory_manager.inherit_project_lessons()
41+
3842
if render_context.required_modules:
3943
previous_module = render_context.required_modules[-1]
4044
console.debug(f"Cloning git repo from module {previous_module.module_name}.")

tests/test_prepare_repositories_layout.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
import pytest
1717

18+
from memory_management import MemoryManager
1819
from plain_modules import PlainModule
1920
from render_machine.actions.prepare_repositories import PrepareRepositories
2021
from render_machine.conformance_tests import CONFORMANCE_TESTS_DEFINITION_FILE_NAME, ConformanceTests
@@ -46,6 +47,11 @@ def _make_render_context(module: PlainModule, render_conformance_tests: bool) ->
4647
render_conformance_tests=render_conformance_tests,
4748
conformance_tests=ConformanceTests(module.build_folder, CONFORMANCE_TESTS_DEFINITION_FILE_NAME),
4849
base_folder=None,
50+
# Preparing the folders is also where the project lessons are carried forward, since it is what
51+
# deletes the folder they have to live in.
52+
memory_manager=MemoryManager(
53+
None, module.module_memory_folder, module.project_memory_folder, predecessor_memory_folder=None
54+
),
4955
)
5056

5157

tests/test_project_lessons_inheritance.py

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@
88

99
import json
1010
import os
11+
from types import SimpleNamespace
1112
from unittest.mock import MagicMock
1213

1314
import pytest
1415

1516
from memory_management import CONFORMANCE_TEST_LESSONS_FILE_NAME, PROJECT_LESSONS_FILE_NAME, MemoryManager
1617
from module_renderer import ModuleRenderer
18+
from render_machine.actions.prepare_repositories import PrepareRepositories
1719

1820
POM_LESSON = {"lesson": "Conformance test projects must declare spring-boot-starter-parent.", "scope": "project"}
1921
BINDER_LESSON = {"lesson": "The SLF4J binder must be logback-classic.", "scope": "project"}
@@ -68,7 +70,7 @@ def test_a_module_inherits_the_project_lessons_of_the_one_before_it(tmp_path):
6870
predecessor, successor = str(tmp_path / "a"), str(tmp_path / "b")
6971
_write_lessons(predecessor, PROJECT_LESSONS_FILE_NAME, [POM_LESSON])
7072

71-
MemoryManager.inherit_project_lessons(predecessor, successor)
73+
MemoryManager(MagicMock(), successor, successor, predecessor).inherit_project_lessons()
7274

7375
assert _read_lessons(successor, PROJECT_LESSONS_FILE_NAME) == [POM_LESSON]
7476

@@ -78,7 +80,7 @@ def test_inheriting_replaces_what_was_there_so_the_file_is_a_snapshot_of_the_cha
7880
_write_lessons(predecessor, PROJECT_LESSONS_FILE_NAME, [POM_LESSON])
7981
_write_lessons(successor, PROJECT_LESSONS_FILE_NAME, [BINDER_LESSON])
8082

81-
MemoryManager.inherit_project_lessons(predecessor, successor)
83+
MemoryManager(MagicMock(), successor, successor, predecessor).inherit_project_lessons()
8284

8385
assert _read_lessons(successor, PROJECT_LESSONS_FILE_NAME) == [POM_LESSON]
8486

@@ -87,7 +89,7 @@ def test_the_module_own_lessons_are_not_inherited(tmp_path):
8789
predecessor, successor = str(tmp_path / "a"), str(tmp_path / "b")
8890
_write_lessons(predecessor, CONFORMANCE_TEST_LESSONS_FILE_NAME, [{"lesson": "about HttpClient"}])
8991

90-
MemoryManager.inherit_project_lessons(predecessor, successor)
92+
MemoryManager(MagicMock(), successor, successor, predecessor).inherit_project_lessons()
9193

9294
assert not os.path.exists(os.path.join(successor, CONFORMANCE_TEST_LESSONS_FILE_NAME))
9395

@@ -98,15 +100,15 @@ def test_a_predecessor_that_learned_nothing_leaves_what_is_already_here_alone(tm
98100
os.makedirs(predecessor, exist_ok=True)
99101
_write_lessons(successor, PROJECT_LESSONS_FILE_NAME, [BINDER_LESSON])
100102

101-
MemoryManager.inherit_project_lessons(predecessor, successor)
103+
MemoryManager(MagicMock(), successor, successor, predecessor).inherit_project_lessons()
102104

103105
assert _read_lessons(successor, PROJECT_LESSONS_FILE_NAME) == [BINDER_LESSON]
104106

105107

106108
def test_the_first_module_in_the_chain_has_nothing_to_inherit(tmp_path):
107109
successor = str(tmp_path / "b")
108110

109-
MemoryManager.inherit_project_lessons(None, successor)
111+
MemoryManager(MagicMock(), successor, successor, None).inherit_project_lessons()
110112

111113
assert not os.path.exists(os.path.join(successor, PROJECT_LESSONS_FILE_NAME))
112114

@@ -171,3 +173,45 @@ def test_a_module_outside_this_render_has_no_predecessor(chain):
171173
_, _, top = chain
172174

173175
assert _renderer(top)._predecessor_memory_folder(_module("unrelated")) is None
176+
177+
178+
# --- the copy has to outlive the render's own setup --------------------------------------------------------
179+
180+
181+
def test_the_inherited_file_survives_preparing_the_module_folder(tmp_path):
182+
"""Preparing a module deletes its folder outright, .memory included.
183+
184+
The copy was originally made before the render started, which put it in the folder that the very first
185+
action then deleted. Every module in the d365 chain ended up with only its own lessons, and nothing in the
186+
unit tests noticed because none of them exercised the two steps together.
187+
188+
Built on SimpleNamespace rather than MagicMock deliberately: a MagicMock attribute satisfies os.fspath and
189+
silently becomes a real directory, so a missing path reads as a passing test.
190+
"""
191+
predecessor = str(tmp_path / "predecessor" / ".memory")
192+
_write_lessons(predecessor, PROJECT_LESSONS_FILE_NAME, [POM_LESSON])
193+
194+
module_folder = tmp_path / "build" / "the_module"
195+
memory_folder = module_folder / ".memory"
196+
code_folder = module_folder / "code"
197+
os.makedirs(str(memory_folder), exist_ok=True)
198+
os.makedirs(str(code_folder), exist_ok=True)
199+
# Something that must not survive the wipe, standing in for a previous render's output.
200+
(code_folder / "stale.java").write_text("stale", encoding="utf-8")
201+
202+
render_context = SimpleNamespace(
203+
render_range=None,
204+
required_modules=[],
205+
render_conformance_tests=False,
206+
base_folder=None,
207+
build_folder=str(code_folder),
208+
module_name="the_module",
209+
run_state=SimpleNamespace(render_id="test-render-id"),
210+
plain_module=SimpleNamespace(module_folder=str(module_folder), seed_module_metadata=lambda: None),
211+
memory_manager=MemoryManager(None, str(memory_folder), str(memory_folder), predecessor),
212+
)
213+
214+
PrepareRepositories().execute(render_context, None)
215+
216+
assert not os.path.exists(str(code_folder / "stale.java")), "the wipe must still happen"
217+
assert _read_lessons(str(memory_folder), PROJECT_LESSONS_FILE_NAME) == [POM_LESSON]

0 commit comments

Comments
 (0)